feat: repo delete endpoint (DB + PDS record removal)
Author: Aaron Steven White
Commit
74072e72e8ea4d334d38d39a449f8f74f219773fParent: e8a70b1cb4
Structural diff unavailable
These commits were pushed via plain git push, so no pre-parsed
schemas are available. Install git-remote-cospan and re-push via panproto:// to
see scope-level changes, breaking change detection, and semantic diffs.
brew install panproto/tap/git-remote-cospan4 files changed +151 -1
@@ -167,3 +167,67 @@ async fn parse_create_response(
167167 cid: body.cid, 168168 }) 169169 } 170+ 171+/// Delete a record from the authenticated user's PDS. 172+pub async fn delete_record( 173+ http: &reqwest::Client, 174+ session: &Session, 175+ collection: &str, 176+ rkey: &str, 177+) -> Result<(), PdsClientError> { 178+ let dpop_key = DpopKey::from_private_key_b64( 179+ &session.dpop_private_key_b64, 180+ format!("session-{}", session.did), 181+ ) 182+ .map_err(|e| PdsClientError::DpopKey(e.to_string()))?; 183+ 184+ let url = format!( 185+ "{}/xrpc/com.atproto.repo.deleteRecord", 186+ session.pds_url.trim_end_matches('/') 187+ ); 188+ 189+ let body = serde_json::json!({ 190+ "repo": session.did, 191+ "collection": collection, 192+ "rkey": rkey, 193+ }); 194+ 195+ let resp = send_dpop_post( 196+ http, 197+ &dpop_key, 198+ &url, 199+ &session.access_token, 200+ session.dpop_nonce.as_deref(), 201+ &body, 202+ ) 203+ .await?; 204+ 205+ if resp.status().is_success() { 206+ return Ok(()); 207+ } 208+ 209+ // Nonce retry 210+ let new_nonce = resp 211+ .headers() 212+ .get("dpop-nonce") 213+ .and_then(|v| v.to_str().ok()) 214+ .map(String::from); 215+ let status = resp.status(); 216+ let body_text = resp.text().await.unwrap_or_default(); 217+ 218+ if (status.as_u16() == 401 || status.as_u16() == 400) 219+ && (body_text.contains("use_dpop_nonce") || body_text.contains("DPoP")) 220+ { 221+ if let Some(ref nonce) = new_nonce { 222+ let retry = send_dpop_post(http, &dpop_key, &url, &session.access_token, Some(nonce), &body).await?; 223+ if retry.status().is_success() { 224+ return Ok(()); 225+ } 226+ } 227+ } 228+ 229+ Err(PdsClientError::PdsError { 230+ status: status.as_u16(), 231+ body: body_text, 232+ }) 233+}
@@ -1,4 +1,4 @@
1-pub use super::generated::crud::repos::{get, list, upsert}; 1+pub use super::generated::crud::repos::{delete, get, list, upsert}; 22 pub use super::generated::types::RepoRow; 33 44 use chrono::{DateTime, Utc};
@@ -27,6 +27,7 @@ mod reaction_list;
2727 mod ref_update_list; 2828 mod push_token; 2929 mod repo_create; 30+mod repo_delete; 3031 mod repo_fork; 3132 mod repo_get; 3233 mod repo_import;
@@ -182,6 +183,7 @@ pub fn router(state: Arc<AppState>) -> Router {
182183 ) 183184 .route("/xrpc/dev.cospan.repo.create", post(repo_create::handler)) 184185 .route("/xrpc/dev.cospan.repo.fork", post(repo_fork::handler)) 186+ .route("/xrpc/dev.cospan.repo.delete", post(repo_delete::handler)) 185187 .route("/xrpc/dev.cospan.repo.import", post(repo_import::handler)) 186188 .route( 187189 "/xrpc/dev.cospan.repo.createPushToken",
@@ -0,0 +1,84 @@
1+use std::sync::Arc; 2+ 3+use axum::Json; 4+use axum::extract::State; 5+use axum::http::HeaderMap; 6+use serde::Deserialize; 7+ 8+use crate::auth::oauth::extract_session_id; 9+use crate::auth::pds_client; 10+use crate::db; 11+use crate::error::AppError; 12+use crate::state::AppState; 13+ 14+#[derive(Deserialize)] 15+#[serde(rename_all = "camelCase")] 16+pub struct Input { 17+ /// DID of the repo owner. 18+ pub did: String, 19+ /// Name of the repo to delete. 20+ pub name: String, 21+} 22+ 23+/// POST /xrpc/dev.cospan.repo.delete 24+/// 25+/// Deletes a repo from the appview DB. If the session has PDS 26+/// credentials (full OAuth, not bridge-only), also deletes the 27+/// dev.cospan.repo record from the user's PDS. 28+/// 29+/// Requires an authenticated session. The session's DID must match 30+/// the repo's DID (you can only delete your own repos). 31+pub async fn handler( 32+ State(state): State<Arc<AppState>>, 33+ headers: HeaderMap, 34+ Json(input): Json<Input>, 35+) -> Result<Json<serde_json::Value>, AppError> { 36+ let session_id = extract_session_id(&headers) 37+ .ok_or_else(|| AppError::Unauthorized("sign in required".to_string()))?; 38+ let session = state 39+ .session_store 40+ .get_session(&session_id) 41+ .await 42+ .map_err(|e| AppError::Upstream(format!("session lookup: {e}")))? 43+ .ok_or_else(|| AppError::Unauthorized("session not found".to_string()))?; 44+ 45+ if session.did != input.did { 46+ return Err(AppError::Unauthorized( 47+ "you can only delete repos you own".to_string(), 48+ )); 49+ } 50+ 51+ // Look up the repo to get its rkey (needed for PDS deletion). 52+ let repo = db::repo::get(&state.db, &input.did, &input.name) 53+ .await? 54+ .ok_or_else(|| AppError::NotFound(format!("repo {}/{} not found", input.did, input.name)))?; 55+ 56+ // Try to delete the PDS record if the session has PDS credentials. 57+ if !session.pds_url.is_empty() && !session.access_token.is_empty() { 58+ match pds_client::delete_record( 59+ &state.http_client, 60+ &session, 61+ "dev.cospan.repo", 62+ &repo.rkey, 63+ ) 64+ .await 65+ { 66+ Ok(_) => tracing::info!(did = %input.did, name = %input.name, "PDS record deleted"), 67+ Err(e) => tracing::warn!( 68+ did = %input.did, name = %input.name, error = %e, 69+ "PDS record deletion failed (continuing with DB delete)" 70+ ), 71+ } 72+ } 73+ 74+ // Delete from the appview DB. 75+ db::repo::delete(&state.db, &input.did, &input.name).await?; 76+ 77+ tracing::info!(did = %input.did, name = %input.name, "repo deleted"); 78+ 79+ Ok(Json(serde_json::json!({ 80+ "ok": true, 81+ "did": input.did, 82+ "name": input.name, 83+ }))) 84+}