this repo has no description

Initial account deletion request endpoint

+16
.sqlx/query-a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Timestamptz" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff" 16 + }
+1 -1
TODO.md
··· 34 34 - [x] Implement `com.atproto.server.getAccountInviteCodes`. 35 35 - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). 36 36 - [x] Implement `com.atproto.server.listAppPasswords`. 37 - - [ ] Implement `com.atproto.server.requestAccountDelete`. 37 + - [x] Implement `com.atproto.server.requestAccountDelete`. 38 38 - [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. 39 39 - [ ] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. 40 40 - [ ] Implement `com.atproto.server.reserveSigningKey`.
+6
migrations/202512211900_account_deletion_tokens.sql
··· 1 + CREATE TABLE IF NOT EXISTS account_deletion_requests ( 2 + token TEXT PRIMARY KEY, 3 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 4 + expires_at TIMESTAMPTZ NOT NULL, 5 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 6 + );
+1 -1
src/api/server/mod.rs
··· 7 7 pub use session::{ 8 8 activate_account, check_account_status, create_app_password, create_session, 9 9 deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords, 10 - refresh_session, revoke_app_password, 10 + refresh_session, request_account_delete, revoke_app_password, 11 11 };
+88
src/api/server/session.rs
··· 6 6 response::{IntoResponse, Response}, 7 7 }; 8 8 use bcrypt::verify; 9 + use chrono::{Duration, Utc}; 10 + use uuid::Uuid; 9 11 use serde::{Deserialize, Serialize}; 10 12 use serde_json::json; 11 13 use tracing::{error, info, warn}; ··· 338 340 Json(json!({"error": "AuthenticationFailed"})), 339 341 ) 340 342 .into_response() 343 + } 344 + 345 + pub async fn request_account_delete( 346 + State(state): State<AppState>, 347 + headers: axum::http::HeaderMap, 348 + ) -> Response { 349 + let auth_header = headers.get("Authorization"); 350 + if auth_header.is_none() { 351 + return ( 352 + StatusCode::UNAUTHORIZED, 353 + Json(json!({"error": "AuthenticationRequired"})), 354 + ) 355 + .into_response(); 356 + } 357 + 358 + let token = auth_header 359 + .unwrap() 360 + .to_str() 361 + .unwrap_or("") 362 + .replace("Bearer ", ""); 363 + 364 + let session = sqlx::query!( 365 + r#" 366 + SELECT s.did, k.key_bytes 367 + FROM sessions s 368 + JOIN users u ON s.did = u.did 369 + JOIN user_keys k ON u.id = k.user_id 370 + WHERE s.access_jwt = $1 371 + "#, 372 + token 373 + ) 374 + .fetch_optional(&state.db) 375 + .await; 376 + 377 + let (did, key_bytes) = match session { 378 + Ok(Some(row)) => (row.did, row.key_bytes), 379 + Ok(None) => { 380 + return ( 381 + StatusCode::UNAUTHORIZED, 382 + Json(json!({"error": "AuthenticationFailed"})), 383 + ) 384 + .into_response(); 385 + } 386 + Err(e) => { 387 + error!("DB error in request_account_delete: {:?}", e); 388 + return ( 389 + StatusCode::INTERNAL_SERVER_ERROR, 390 + Json(json!({"error": "InternalError"})), 391 + ) 392 + .into_response(); 393 + } 394 + }; 395 + 396 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 397 + return ( 398 + StatusCode::UNAUTHORIZED, 399 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 400 + ) 401 + .into_response(); 402 + } 403 + 404 + let confirmation_token = Uuid::new_v4().to_string(); 405 + let expires_at = Utc::now() + Duration::minutes(15); 406 + 407 + let insert = sqlx::query!( 408 + "INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)", 409 + confirmation_token, 410 + did, 411 + expires_at 412 + ) 413 + .execute(&state.db) 414 + .await; 415 + 416 + if let Err(e) = insert { 417 + error!("DB error creating deletion token: {:?}", e); 418 + return ( 419 + StatusCode::INTERNAL_SERVER_ERROR, 420 + Json(json!({"error": "InternalError"})), 421 + ) 422 + .into_response(); 423 + } 424 + 425 + // TODO: Send email or other notification 426 + info!("Account deletion requested for user {}, token: {}", did, confirmation_token); 427 + 428 + (StatusCode::OK, Json(json!({}))).into_response() 341 429 } 342 430 343 431 pub async fn refresh_session(
+4
src/lib.rs
··· 151 151 post(api::server::deactivate_account), 152 152 ) 153 153 .route( 154 + "/xrpc/com.atproto.server.requestAccountDelete", 155 + post(api::server::request_account_delete), 156 + ) 157 + .route( 154 158 "/xrpc/com.atproto.identity.updateHandle", 155 159 post(api::identity::update_handle), 156 160 )
+8
tests/common/mod.rs
··· 202 202 } 203 203 204 204 #[allow(dead_code)] 205 + pub async fn get_db_connection_string() -> String { 206 + base_url().await; 207 + let container = DB_CONTAINER.get().expect("DB container not initialized"); 208 + let port = container.get_host_port_ipv4(5432).await.expect("Failed to get port"); 209 + format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port) 210 + } 211 + 212 + #[allow(dead_code)] 205 213 pub async fn upload_test_blob(client: &Client, data: &'static str, mime: &'static str) -> Value { 206 214 let res = client 207 215 .post(format!(
+3
tests/helpers/mod.rs
··· 96 96 (uri, cid) 97 97 } 98 98 99 + #[allow(dead_code)] 99 100 pub async fn create_follow( 100 101 client: &reqwest::Client, 101 102 follower_did: &str, ··· 142 143 (uri, cid) 143 144 } 144 145 146 + #[allow(dead_code)] 145 147 pub async fn create_like( 146 148 client: &reqwest::Client, 147 149 liker_did: &str, ··· 186 188 ) 187 189 } 188 190 191 + #[allow(dead_code)] 189 192 pub async fn create_repost( 190 193 client: &reqwest::Client, 191 194 reposter_did: &str,
+32 -1
tests/lifecycle_session.rs
··· 442 442 assert_eq!(claims["iss"], did); 443 443 assert_eq!(claims["aud"], "did:web:api.bsky.app"); 444 444 assert_eq!(claims["lxm"], "com.atproto.repo.uploadBlob"); 445 - } 445 + } 446 + 447 + #[tokio::test] 448 + async fn test_request_account_delete() { 449 + let client = client(); 450 + let (did, jwt) = setup_new_user("request-delete-test").await; 451 + 452 + let res = client 453 + .post(format!( 454 + "{}/xrpc/com.atproto.server.requestAccountDelete", 455 + base_url().await 456 + )) 457 + .bearer_auth(&jwt) 458 + .send() 459 + .await 460 + .expect("Failed to request account deletion"); 461 + 462 + assert_eq!(res.status(), StatusCode::OK); 463 + 464 + let db_url = get_db_connection_string().await; 465 + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); 466 + 467 + let row = sqlx::query!("SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", did) 468 + .fetch_optional(&pool) 469 + .await 470 + .expect("Failed to query DB"); 471 + 472 + assert!(row.is_some(), "Deletion token should exist in DB"); 473 + let row = row.unwrap(); 474 + assert!(!row.token.is_empty(), "Token should not be empty"); 475 + assert!(row.expires_at > Utc::now(), "Token should not be expired"); 476 + }
-1
tests/sync_repo.rs
··· 6 6 use reqwest::StatusCode; 7 7 use reqwest::header; 8 8 use serde_json::{Value, json}; 9 - use chrono::Utc; 10 9 11 10 #[tokio::test] 12 11 async fn test_get_latest_commit_success() {