From 67db2a9a3cdadce9aa93d7e7ca9e283d345c2745 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 31 Dec 2025 18:45:12 -0600 Subject: [PATCH] feat: add tracing to OAuth handlers * refactor repository initialization * add just scripts and local dev docs --- README.md | 1 + crates/server/src/api/oauth.rs | 69 +++++++++++++--- crates/server/src/lib.rs | 109 ++++++++++++++++++------ crates/server/src/oauth/flow.rs | 141 +++++++++++++++++++++++++------- crates/server/src/state.rs | 42 ++++++++++ crates/server/src/well_known.rs | 3 +- docs/local-dev.md | 114 ++++++++++++++++++++++++++ docs/todo.md | 31 ++++--- justfile | 81 ++++++++++++++++++ 9 files changed, 501 insertions(+), 90 deletions(-) create mode 100644 docs/local-dev.md create mode 100644 justfile diff --git a/README.md b/README.md index cdd32ae..2915652 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ and sharing progress & notes. ## Documentation +- [Local Development](./docs/local-dev.md) - Setup and testing guide - [Personas & Principles](./docs/personas.md) - Target users and design philosophy - [Architecture](./docs/architecture.md) - System components and data model - [Information Architecture](./docs/information-architecture.md) - Navigation and URL structure diff --git a/crates/server/src/api/oauth.rs b/crates/server/src/api/oauth.rs index 7b7ff57..9be52dc 100644 --- a/crates/server/src/api/oauth.rs +++ b/crates/server/src/api/oauth.rs @@ -105,19 +105,31 @@ pub struct CallbackQuery { pub async fn authorize( State(oauth): State>, Json(payload): Json, ) -> impl IntoResponse { + tracing::info!("OAuth authorization request received for handle: {}", payload.handle); + let state = generate_state(); + tracing::debug!("Generated state parameter: {}", state); match oauth .flow .start_authorization(&payload.handle, &state, &oauth.sessions) .await { - Ok(auth_url) => ( - StatusCode::OK, - Json(AuthorizeResponse { authorization_url: auth_url, state }), - ) - .into_response(), - Err(e) => (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response(), + Ok(auth_url) => { + tracing::info!( + "OAuth authorization started successfully for handle: {}", + payload.handle + ); + ( + StatusCode::OK, + Json(AuthorizeResponse { authorization_url: auth_url, state }), + ) + .into_response() + } + Err(e) => { + tracing::error!("OAuth authorization failed for handle {}: {}", payload.handle, e); + (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response() + } } } @@ -125,8 +137,11 @@ pub async fn authorize( /// /// GET /api/oauth/callback?code=...&state=... pub async fn callback(State(oauth): State>, Query(params): Query) -> impl IntoResponse { + tracing::info!("OAuth callback received with state: {}", params.state); + if let Some(error) = params.error { let description = params.error_description.unwrap_or_default(); + tracing::error!("OAuth authorization error: {} - {}", error, description); return Redirect::to(&format!( "/login?error={}&description={}", urlencoding::encode(&error), @@ -135,14 +150,19 @@ pub async fn callback(State(oauth): State>, Query(params): Query .into_response(); } + tracing::debug!("Retrieving session for state: {}", params.state); let session = { let sessions = oauth.sessions.read().unwrap(); sessions.get(¶ms.state).cloned() }; let session = match session { - Some(s) => s, + Some(s) => { + tracing::debug!("Session found for state: {}", params.state); + s + } None => { + tracing::error!("Session not found for state: {}", params.state); return Redirect::to("/login?error=session_not_found").into_response(); } }; @@ -153,12 +173,13 @@ pub async fn callback(State(oauth): State>, Query(params): Query .await { Ok(tokens) => { - let did = session.did.unwrap_or_default(); + let did = session.did.clone().unwrap_or_default(); let pds_url = session.pds_url.unwrap_or_default(); let expires_at = tokens .expires_in .map(|secs| Utc::now() + Duration::seconds(secs as i64)); + tracing::info!("Storing tokens for DID: {}", did); if let Err(e) = oauth .repo .store_tokens(StoreTokensRequest { @@ -172,14 +193,18 @@ pub async fn callback(State(oauth): State>, Query(params): Query }) .await { - tracing::error!("Failed to store tokens: {}", e); + tracing::error!("Failed to store tokens for DID {}: {}", did, e); return Redirect::to(&format!("/login?error={}", urlencoding::encode("token_storage_failed"))) .into_response(); } + tracing::info!("OAuth flow completed successfully for DID: {}", did); Redirect::to(&format!("/login/success?did={}", urlencoding::encode(&did))).into_response() } - Err(e) => Redirect::to(&format!("/login?error={}", urlencoding::encode(&e.to_string()))).into_response(), + Err(e) => { + tracing::error!("Token exchange failed: {}", e); + Redirect::to(&format!("/login?error={}", urlencoding::encode(&e.to_string()))).into_response() + } } } @@ -201,18 +226,27 @@ pub struct RefreshResponse { /// POST /api/oauth/refresh /// Body: { "did": "did:plc:..." } pub async fn refresh(State(oauth): State>, Json(payload): Json) -> impl IntoResponse { + tracing::info!("Token refresh request for DID: {}", payload.did); + // Get stored tokens from database + tracing::debug!("Retrieving stored tokens from database for DID: {}", payload.did); let stored = match oauth.repo.get_tokens(&payload.did).await { - Ok(t) => t, + Ok(t) => { + tracing::debug!("Found stored tokens for DID: {}", payload.did); + t + } Err(e) => { + tracing::error!("Failed to retrieve stored tokens for DID {}: {}", payload.did, e); return (StatusCode::NOT_FOUND, Json(json!({ "error": e.to_string() }))).into_response(); } }; // Reconstruct DPoP keypair + tracing::debug!("Reconstructing DPoP keypair from stored data"); let dpop_keypair = match stored.dpop_keypair() { Some(kp) => kp, None => { + tracing::error!("Failed to reconstruct DPoP keypair for DID: {}", payload.did); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Invalid stored keypair" })), @@ -225,6 +259,7 @@ pub async fn refresh(State(oauth): State>, Json(payload): Json rt.clone(), None => { + tracing::error!("No refresh token available for DID: {}", payload.did); return ( StatusCode::BAD_REQUEST, Json(json!({ "error": "No refresh token available" })), @@ -244,6 +279,10 @@ pub async fn refresh(State(oauth): State>, Json(payload): Json>, Json(payload): Json>, Json(payload): Json (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response(), + Err(e) => { + tracing::error!("Token refresh failed for DID {}: {}", payload.did, e); + (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response() + } } } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 0b70bde..c7f3e29 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -11,12 +11,13 @@ pub mod well_known; use axum::http::Method; use axum::{ Json, Router, + extract::State, http::StatusCode, middleware as axum_middleware, response::{IntoResponse, Response}, routing::{get, post}, }; -use serde_json::json; +use serde_json::{Value, json}; use std::net::SocketAddr; use tokio::net::TcpListener; use tower_http::cors::{Any, CorsLayer}; @@ -43,29 +44,9 @@ pub async fn start() -> malfestio_core::Result<()> { tracing::info!("Database connection pool created"); - let oauth_repo = std::sync::Arc::new(repository::oauth::DbOAuthRepository::new(pool.clone())); - let deck_repo = std::sync::Arc::new(repository::deck::DbDeckRepository::new(pool.clone())); - let card_repo = std::sync::Arc::new(repository::card::DbCardRepository::new(pool.clone())); - let note_repo = std::sync::Arc::new(repository::note::DbNoteRepository::new(pool.clone())); - let prefs_repo = std::sync::Arc::new(repository::preferences::DbPreferencesRepository::new(pool.clone())); - let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); - let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); - - let search_repo = std::sync::Arc::new(repository::search::DbSearchRepository::new(pool.clone())); let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); let config = state::AppConfig { pds_url }; - - let repos = state::Repositories { - oauth: oauth_repo, - deck: deck_repo, - card: card_repo, - note: note_repo, - prefs: prefs_repo, - review: review_repo, - social: social_repo, - search: search_repo, - }; - + let repos = state::Repositories::from(&pool); let state = state::AppState::new(pool, repos, config); let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::new()); @@ -118,6 +99,7 @@ pub async fn start() -> malfestio_core::Result<()> { let app = Router::new() .route("/health", get(health_check)) + .route("/health/ready", get(readiness_check)) .route( "/.well-known/oauth-client-metadata", get(oauth::client_metadata::client_metadata_handler), @@ -146,8 +128,58 @@ pub async fn start() -> malfestio_core::Result<()> { Ok(()) } +/// Basic liveness check - returns 200 if the server is running. +/// +/// For simple uptime monitoring and should always respond quickly without checking external dependencies. async fn health_check() -> impl IntoResponse { - Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") })) + Json(json!({ + "status": "ok", + "service": "malfestio-server", + "version": env!("CARGO_PKG_VERSION") + })) +} + +/// Readiness check - verifies the server can handle requests. +/// +/// Checks database connectivity and other critical dependencies (load balancer health checks and deployment readiness probes). +async fn readiness_check(State(state): State) -> (StatusCode, Json) { + match state.pool.get().await { + Ok(client) => match client.query("SELECT 1", &[]).await { + Ok(_) => ( + StatusCode::OK, + Json(json!({ + "status": "ready", + "service": "malfestio-server", + "version": env!("CARGO_PKG_VERSION"), + "checks": { "database": "ok" } + })), + ), + Err(e) => { + tracing::error!("Readiness check failed: database query error: {}", e); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "status": "not_ready", + "service": "malfestio-server", + "version": env!("CARGO_PKG_VERSION"), + "checks": { "database": "query_failed" } + })), + ) + } + }, + Err(e) => { + tracing::error!("Readiness check failed: unable to get database connection: {}", e); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "status": "not_ready", + "service": "malfestio-server", + "version": env!("CARGO_PKG_VERSION"), + "checks": { "database": "connection_failed" } + })), + ) + } + } } pub struct AppError(malfestio_core::Error); @@ -160,10 +192,33 @@ impl IntoResponse for AppError { _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error".to_string()), }; - let body = Json(json!({ - "error": error_message, - })); + (status, Json(json!({ "error": error_message }))).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_health_check_response_format() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let response = health_check().await.into_response(); + assert_eq!(response.status(), StatusCode::OK); + }); + } - (status, body).into_response() + #[test] + fn test_readiness_check_with_unavailable_db() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let pool = db::create_mock_pool(); + let repos = state::Repositories::default(); + let config = state::AppConfig { pds_url: "https://test.example.com".to_string() }; + let app_state = state::AppState::new(pool, repos, config); + let (status, _json) = readiness_check(State(app_state)).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + }); } } diff --git a/crates/server/src/oauth/flow.rs b/crates/server/src/oauth/flow.rs index a4e669f..dd370b5 100644 --- a/crates/server/src/oauth/flow.rs +++ b/crates/server/src/oauth/flow.rs @@ -73,31 +73,48 @@ impl OAuthFlow { pub async fn start_authorization( &self, handle_or_did: &str, state: &str, sessions: &SessionStore, ) -> Result { + tracing::info!("Starting OAuth authorization for: {}", handle_or_did); + let (did, pds_url) = if handle_or_did.starts_with("did:") { + tracing::debug!("Input is a DID, resolving directly: {}", handle_or_did); let resolved = self.resolver.resolve_did(handle_or_did).await?; + tracing::info!("DID resolved to PDS: {}", resolved.pds_url); (resolved.did, resolved.pds_url) } else { + tracing::debug!("Input is a handle, resolving to DID: {}", handle_or_did); let did = self.resolver.resolve_handle(handle_or_did).await?; + tracing::info!("Handle resolved to DID: {}", did); + let resolved = self.resolver.resolve_did(&did).await?; + tracing::info!("DID resolved to PDS: {}", resolved.pds_url); (resolved.did, resolved.pds_url) }; + tracing::debug!("Fetching authorization server metadata from PDS: {}", pds_url); let auth_server = self.get_auth_server_metadata(&pds_url).await?; + tracing::info!( + "Authorization server metadata retrieved - issuer: {}, authorization_endpoint: {}", + auth_server.issuer, + auth_server.authorization_endpoint + ); + tracing::debug!("Generating PKCE code verifier and challenge"); let code_verifier = generate_code_verifier(); let code_challenge = derive_code_challenge(&code_verifier); + tracing::debug!("Generating DPoP keypair for session"); let dpop_keypair = DpopKeypair::generate(); let session = OAuthSession { code_verifier, dpop_keypair, did: Some(did.clone()), - pds_url: Some(pds_url), + pds_url: Some(pds_url.clone()), created_at: std::time::Instant::now(), }; sessions.write().unwrap().insert(state.to_string(), session); + tracing::debug!("OAuth session stored with state: {}", state); let auth_url = format!( "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256&login_hint={}", @@ -110,6 +127,10 @@ impl OAuthFlow { urlencoding::encode(&did) ); + tracing::info!( + "Authorization URL generated, redirecting user to: {}", + auth_server.authorization_endpoint + ); Ok(auth_url) } @@ -117,21 +138,32 @@ impl OAuthFlow { pub async fn exchange_code( &self, code: &str, state: &str, sessions: &SessionStore, ) -> Result { - let session = sessions - .read() - .unwrap() - .get(state) - .cloned() - .ok_or(OAuthFlowError::SessionNotFound)?; + tracing::info!("Exchanging authorization code for tokens"); - let pds_url = session.pds_url.as_ref().ok_or(OAuthFlowError::SessionNotFound)?; + let session = sessions.read().unwrap().get(state).cloned().ok_or_else(|| { + tracing::error!("OAuth session not found for state: {}", state); + OAuthFlowError::SessionNotFound + })?; + tracing::debug!("Session retrieved, DID: {:?}", session.did); + + let pds_url = session.pds_url.as_ref().ok_or_else(|| { + tracing::error!("PDS URL missing from session"); + OAuthFlowError::SessionNotFound + })?; + + tracing::debug!("Fetching authorization server metadata for token exchange"); let auth_server = self.get_auth_server_metadata(pds_url).await?; + tracing::debug!( + "Generating DPoP proof for token endpoint: {}", + auth_server.token_endpoint + ); let dpop_proof = session .dpop_keypair .generate_proof("POST", &auth_server.token_endpoint, None); + tracing::info!("Sending token exchange request to: {}", auth_server.token_endpoint); let response = self .client .post(&auth_server.token_endpoint) @@ -145,20 +177,28 @@ impl OAuthFlow { ]) .send() .await - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; + .map_err(|e| { + tracing::error!("Network error during token exchange: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + })?; - if !response.status().is_success() { + let status = response.status(); + if !status.is_success() { let error_body = response.text().await.unwrap_or_default(); + tracing::error!("Token exchange failed with status {}: {}", status, error_body); return Err(OAuthFlowError::TokenExchangeFailed(error_body)); } - let tokens: OAuthTokens = response - .json() - .await - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; + tracing::debug!("Token exchange successful, parsing response"); + let tokens: OAuthTokens = response.json().await.map_err(|e| { + tracing::error!("Failed to parse token response: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + })?; + tracing::info!("Tokens received successfully, cleaning up session"); sessions.write().unwrap().remove(state); + tracing::info!("OAuth token exchange completed successfully"); Ok(tokens) } @@ -166,10 +206,18 @@ impl OAuthFlow { pub async fn refresh_token( &self, refresh_token: &str, pds_url: &str, dpop_keypair: &DpopKeypair, ) -> Result { + tracing::info!("Refreshing access token for PDS: {}", pds_url); + + tracing::debug!("Fetching authorization server metadata for token refresh"); let auth_server = self.get_auth_server_metadata(pds_url).await?; + tracing::debug!( + "Generating DPoP proof for token endpoint: {}", + auth_server.token_endpoint + ); let dpop_proof = dpop_keypair.generate_proof("POST", &auth_server.token_endpoint, None); + tracing::info!("Sending token refresh request to: {}", auth_server.token_endpoint); let response = self .client .post(&auth_server.token_endpoint) @@ -181,23 +229,33 @@ impl OAuthFlow { ]) .send() .await - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; + .map_err(|e| { + tracing::error!("Network error during token refresh: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + })?; - if !response.status().is_success() { + let status = response.status(); + if !status.is_success() { let error_body = response.text().await.unwrap_or_default(); + tracing::error!("Token refresh failed with status {}: {}", status, error_body); return Err(OAuthFlowError::TokenRefreshFailed(error_body)); } - response - .json() - .await - .map_err(|e| OAuthFlowError::NetworkError(e.to_string())) + tracing::debug!("Token refresh successful, parsing response"); + let result = response.json().await.map_err(|e| { + tracing::error!("Failed to parse token refresh response: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + })?; + + tracing::info!("Token refresh completed successfully"); + Ok(result) } /// Get authorization server metadata from PDS. async fn get_auth_server_metadata(&self, pds_url: &str) -> Result { // First get the protected resource metadata let resource_url = format!("{}/.well-known/oauth-protected-resource", pds_url); + tracing::debug!("Fetching protected resource metadata from: {}", resource_url); let resource_response = self .client @@ -205,24 +263,37 @@ impl OAuthFlow { .timeout(std::time::Duration::from_secs(10)) .send() .await - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; + .map_err(|e| { + tracing::error!("Failed to fetch protected resource metadata: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + })?; if !resource_response.status().is_success() { + tracing::error!( + "Protected resource metadata fetch failed with status: {}", + resource_response.status() + ); return Err(OAuthFlowError::MetadataFetchFailed(pds_url.to_string())); } - let resource: serde_json::Value = resource_response - .json() - .await - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; + let resource: serde_json::Value = resource_response.json().await.map_err(|e| { + tracing::error!("Failed to parse protected resource metadata: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + })?; let auth_server_url = resource["authorization_servers"] .as_array() .and_then(|arr| arr.first()) .and_then(|v| v.as_str()) - .ok_or_else(|| OAuthFlowError::MetadataFetchFailed(pds_url.to_string()))?; + .ok_or_else(|| { + tracing::error!("No authorization servers found in protected resource metadata"); + OAuthFlowError::MetadataFetchFailed(pds_url.to_string()) + })?; + + tracing::debug!("Authorization server URL: {}", auth_server_url); let auth_meta_url = format!("{}/.well-known/oauth-authorization-server", auth_server_url); + tracing::debug!("Fetching authorization server metadata from: {}", auth_meta_url); let auth_response = self .client @@ -230,16 +301,24 @@ impl OAuthFlow { .timeout(std::time::Duration::from_secs(10)) .send() .await - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; + .map_err(|e| { + tracing::error!("Failed to fetch authorization server metadata: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + })?; if !auth_response.status().is_success() { + tracing::error!( + "Authorization server metadata fetch failed with status: {}", + auth_response.status() + ); return Err(OAuthFlowError::MetadataFetchFailed(auth_server_url.to_string())); } - auth_response - .json() - .await - .map_err(|e| OAuthFlowError::NetworkError(e.to_string())) + tracing::debug!("Authorization server metadata retrieved successfully"); + auth_response.json().await.map_err(|e| { + tracing::error!("Failed to parse authorization server metadata: {}", e); + OAuthFlowError::NetworkError(e.to_string()) + }) } } diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index bccc4be..7d8824f 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -1,5 +1,6 @@ use crate::db::DbPool; use crate::middleware::auth::UserContext; +use crate::repository; use crate::repository::card::CardRepository; use crate::repository::deck::DeckRepository; use crate::repository::note::NoteRepository; @@ -9,6 +10,7 @@ use crate::repository::review::ReviewRepository; use crate::repository::search::SearchRepository; use crate::repository::social::SocialRepository; +use deadpool_postgres::Pool; use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; @@ -37,6 +39,46 @@ pub struct Repositories { pub search: Arc, } +#[cfg(test)] +impl Default for Repositories { + fn default() -> Self { + Self { + oauth: Arc::new(repository::oauth::mock::MockOAuthRepository::new()), + deck: Arc::new(repository::deck::mock::MockDeckRepository::new()), + card: Arc::new(repository::card::mock::MockCardRepository::new()), + note: Arc::new(repository::note::mock::MockNoteRepository::new()), + prefs: Arc::new(repository::preferences::mock::MockPreferencesRepository::new()), + review: Arc::new(repository::review::mock::MockReviewRepository::new()), + social: Arc::new(repository::social::mock::MockSocialRepository::new()), + search: Arc::new(repository::search::mock::MockSearchRepository::new()), + } + } +} + +impl From<&Pool> for Repositories { + fn from(pool: &Pool) -> Self { + let oauth_repo = std::sync::Arc::new(repository::oauth::DbOAuthRepository::new(pool.clone())); + let deck_repo = std::sync::Arc::new(repository::deck::DbDeckRepository::new(pool.clone())); + let card_repo = std::sync::Arc::new(repository::card::DbCardRepository::new(pool.clone())); + let note_repo = std::sync::Arc::new(repository::note::DbNoteRepository::new(pool.clone())); + let prefs_repo = std::sync::Arc::new(repository::preferences::DbPreferencesRepository::new(pool.clone())); + let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); + let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); + let search_repo = std::sync::Arc::new(repository::search::DbSearchRepository::new(pool.clone())); + + Self { + oauth: oauth_repo, + deck: deck_repo, + card: card_repo, + note: note_repo, + prefs: prefs_repo, + review: review_repo, + social: social_repo, + search: search_repo, + } + } +} + pub struct AppState { pub pool: DbPool, pub card_repo: Arc, diff --git a/crates/server/src/well_known.rs b/crates/server/src/well_known.rs index 36247e9..0c7a72a 100644 --- a/crates/server/src/well_known.rs +++ b/crates/server/src/well_known.rs @@ -7,8 +7,7 @@ use axum::response::IntoResponse; /// Handler for `/.well-known/atproto-did`. /// -/// Returns the server's DID from the `ATPROTO_SERVER_DID` environment variable. -/// Used for domain verification in AT Protocol. +/// Returns the server's DID from the `ATPROTO_SERVER_DID` environment variable for domain verification in AT Protocol. pub async fn atproto_did_handler() -> impl IntoResponse { std::env::var("ATPROTO_SERVER_DID").unwrap_or_default() } diff --git a/docs/local-dev.md b/docs/local-dev.md new file mode 100644 index 0000000..7619403 --- /dev/null +++ b/docs/local-dev.md @@ -0,0 +1,114 @@ +# Local Development + +## Prerequisites + +### Required Tools + +- Rust (latest stable) +- Node.js 18+ and pnpm +- PostgreSQL 14+ +- Docker (optional, for containerized Postgres) + +### Bluesky Account Setup + +1. Create a Bluesky account at +2. Generate an App Password (Settings → App Passwords) +3. Configure `.env` with your credentials: + +```bash +APP_USERNAME=your-handle.bsky.social +APP_PASSWORD=your-app-password-here +DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable" +``` + +## Testing OAuth Flow + +### Step-by-Step + +1. **Start PostgreSQL** + + ```bash + # Using Docker + docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:14 + + # Or use your local PostgreSQL installation + ``` + +2. **Run migrations** + + ```bash + just migrate + ``` + +3. **Start backend** + + ```bash + just start + ``` + + Server runs on + +4. **Start frontend** + + ```bash + just web-dev + ``` + + Frontend runs on + +5. **Test OAuth login** + - Navigate to + - Enter your Bluesky handle (e.g., `thunderbot.bsky.social`) + - Authorize the application on bsky.social + - Verify redirect back to app with successful login + +### OAuth Flow Details + +When you enter a handle like `thunderbot.bsky.social`, the system: + +1. **Handle Resolution**: DNS TXT lookup at `_atproto.thunderbot.bsky.social` or HTTP `https://thunderbot.bsky.social/.well-known/atproto-did` +2. **DID Resolution**: Resolved DID (e.g., `did:plc:...`) queries `https://plc.directory` for PDS endpoint +3. **OAuth Discovery**: `https://bsky.social/.well-known/oauth-authorization-server` fetched for endpoints +4. **Authorization**: User redirected to PDS authorization page with PKCE challenge +5. **Token Exchange**: Authorization code exchanged for access/refresh tokens with DPoP binding +6. **Storage**: Tokens stored in database with encrypted DPoP keypair + +## Testing Record Publishing + +After successful OAuth login: + +1. Create a deck or note in the UI +2. Click "Publish" to publish to your PDS +3. Check your Bluesky profile at to see the published record +4. Verify record appears in your AT Protocol repository + +## Environment Variables + +### Required + +```bash +APP_USERNAME=your-handle.bsky.social +APP_PASSWORD=your-app-password +DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable" +``` + +### Optional + +```bash +# Server configuration +SERVER_HOST=127.0.0.1 +SERVER_PORT=8080 + +# Frontend proxy +VITE_API_URL=http://localhost:8080 + +# Logging +RUST_LOG=info,malfestio_server=debug +``` + +## Additional Resources + +- [AT Protocol OAuth Guide](https://docs.bsky.app/blog/oauth-atproto) +- [OAuth Client Implementation](https://docs.bsky.app/docs/advanced-guides/oauth-client) +- [PDS Self-Hosting](https://atproto.com/guides/self-hosting) +- [AT Protocol Specifications](https://atproto.com) diff --git a/docs/todo.md b/docs/todo.md index 38aad2d..930008a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -42,38 +42,35 @@ - [x] OAuth login directly to user's PDS - [x] Handle resolution via DNS TXT or `/.well-known/atproto-did` - - - [x] DPoP token binding for secure API calls +**Local Development:** + +- [x] Document local testing with real Bluesky accounts +- [x] Add justfile commands for common dev tasks +- [x] Environment variable configuration guide +- [x] Update health check endpoint for service monitoring +- [x] Add logging for OAuth flow steps + **Sync & Conflict Resolution:** - [ ] Bi-directional sync: local drafts → PDS records, PDS records → local cache - [ ] Conflict resolution strategy for concurrent edits (last-write-wins or merge UI) - [ ] Offline queue for pending publishes +- [ ] Sync status UI indicators **Deep Linking:** - [ ] AT-URI deep linking from external clients - [ ] Handle `at://` URL scheme in app +- [ ] Link preview generation for shared content #### Acceptance -- User can log in with their existing Bluesky/PDS identity. -- Local drafts sync correctly after reconnecting. - -#### Implementation Details - -**Considerations:** - -- Scalability: substantial compute; caching, DB optimization, distributed processing -- Lexicon Validation: validate schemas, ignore invalid records gracefully -- Account State: track latest processed revision per repo; handle deletions -- Bluesky's AppView uses PostgreSQL or ScyllaDB + image proxy + AppView core - -**Identity:** - -- Use `did:web` for simplicity, `did:plc` for long-term stability -- ATProto OAuth is the forward path +- User can log in with existing Bluesky/PDS identity +- OAuth flow works with production bsky.social accounts +- Developers can test locally using real accounts (see [Local Development Guide](./local-dev.md)) +- Local drafts sync correctly after reconnecting ### Milestone M - Reliability, Observability, Launch (v0.1.0) diff --git a/justfile b/justfile new file mode 100644 index 0000000..dfbf18b --- /dev/null +++ b/justfile @@ -0,0 +1,81 @@ +# Malfestio + +# Build all Rust crates +build: + cargo build + +# Build for release +build-release: + cargo build --release + +# Run the server via CLI +start: + cargo run --bin malfestio-cli start + +# Run all tests +test: + cargo test --quiet + +# Check code without building +check: + cargo check + +# Run clippy lints +lint: + cargo clippy --fix --allow-dirty + +# Format code +fmt: + cargo fmt + +# Install frontend dependencies +web-install: + cd web && pnpm install + +# Run development server +web-dev: + cd web && pnpm dev + +# Build frontend for production +web-build: + cd web && pnpm build + +# Run frontend tests +web-test: + cd web && pnpm test + +# Type check frontend +web-check: + cd web && pnpm check + +# Lint frontend +web-lint: + cd web && pnpm lint + +# Start both backend and frontend (in separate terminals recommended) +dev: + @echo "Start backend: just start" + @echo "Start frontend: just web-dev" + +# Run all tests (backend + frontend) +test-all: test web-test + +# Run database migrations +migrate: + cargo run --bin malfestio-cli migrate + +# Setup and test OAuth flow with real Bluesky account +test-oauth: + @echo "Testing OAuth with Bluesky account..." + @echo "1. Ensure PostgreSQL is running" + @echo "2. Running migrations..." + @just migrate + @echo "3. Start backend with: just start" + @echo "4. Start frontend with: just web-dev" + @echo "5. Navigate to http://localhost:3000/login" + @echo "6. Enter your Bluesky handle from .env" + +# Clean build artifacts +clean: + cargo clean + cd web && rm -rf dist node_modules/.vite -- 2.51.2