feat: repo delete endpoint (DB + PDS record removal)

Author: Aaron Steven White
Commit 74072e72e8ea4d334d38d39a449f8f74f219773f
Parent: 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-cospan
4 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+}
cospan · schematic version control on atproto built on AT Protocol