An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement DELETE /v1/handles/:handle

Adds handle deletion endpoint. Authenticates via session Bearer token,
verifies ownership (403 if not owner), deletes DNS record first then
DB row (DNS-before-DB ordering mirrors create_handle's DB-before-DNS
invariant — keeps orphan risk on the operator-fixable side), returns
204 No Content.

Also extends DnsProvider trait with delete_record and updates
AlwaysOkDns/AlwaysErrDns test doubles in create_handle tests.

authored by

Malpercio and committed by
Tangled
fe5ec7f2 df1f07a7

+430 -1
+15
bruno/delete_handle.bru
··· 1 + meta { 2 + name: Delete Handle 3 + type: http 4 + seq: 28 5 + } 6 + 7 + delete { 8 + url: {{baseUrl}}/v1/handles/{{handle}} 9 + body: none 10 + auth: bearer 11 + } 12 + 13 + auth:bearer { 14 + token: {{sessionToken}} 15 + }
+3 -1
crates/relay/src/app.rs
··· 5 5 use axum::{ 6 6 extract::Path, 7 7 http::Request, 8 - routing::{get, post}, 8 + routing::{delete, get, post}, 9 9 Router, 10 10 }; 11 11 use common::{ApiError, Config, ErrorCode}; ··· 22 22 use crate::routes::create_did::create_did_handler; 23 23 use crate::routes::create_handle::create_handle_handler; 24 24 use crate::routes::create_mobile_account::create_mobile_account; 25 + use crate::routes::delete_handle::delete_handle_handler; 25 26 use crate::routes::create_session::create_session; 26 27 use crate::routes::create_signing_key::create_signing_key; 27 28 use crate::routes::delete_session::delete_session; ··· 196 197 .route("/v1/dids", post(create_did_handler)) 197 198 .route("/v1/dids/:did", get(get_did_handler)) 198 199 .route("/v1/handles", post(create_handle_handler)) 200 + .route("/v1/handles/:handle", delete(delete_handle_handler)) 199 201 .route( 200 202 "/v1/relay/keys", 201 203 get(get_relay_signing_key).post(create_signing_key),
+9
crates/relay/src/dns.rs
··· 99 99 name: &'a str, 100 100 target: &'a str, 101 101 ) -> Pin<Box<dyn Future<Output = Result<(), DnsError>> + Send + 'a>>; 102 + 103 + /// Delete the DNS record for `name` (a subdomain label, e.g. `"alice"`). 104 + /// 105 + /// Called when a handle is deleted. The provider is responsible for locating 106 + /// the record by name within its configured zone. 107 + fn delete_record<'a>( 108 + &'a self, 109 + name: &'a str, 110 + ) -> Pin<Box<dyn Future<Output = Result<(), DnsError>> + Send + 'a>>; 102 111 }
+14
crates/relay/src/routes/create_handle.rs
··· 259 259 ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 260 260 Box::pin(async { Ok(()) }) 261 261 } 262 + 263 + fn delete_record<'a>( 264 + &'a self, 265 + _name: &'a str, 266 + ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 267 + Box::pin(async { Ok(()) }) 268 + } 262 269 } 263 270 264 271 impl crate::dns::DnsProvider for AlwaysErrDns { ··· 266 273 &'a self, 267 274 _name: &'a str, 268 275 _target: &'a str, 276 + ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 277 + Box::pin(async { Err(crate::dns::DnsError("simulated provider error".to_string())) }) 278 + } 279 + 280 + fn delete_record<'a>( 281 + &'a self, 282 + _name: &'a str, 269 283 ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 270 284 Box::pin(async { Err(crate::dns::DnsError("simulated provider error".to_string())) }) 271 285 }
+388
crates/relay/src/routes/delete_handle.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // DELETE /v1/handles/:handle — Remove a handle from the account and clean up DNS. 4 + // 5 + // Inputs: 6 + // - Authorization: Bearer <session_token> 7 + // - Path: :handle (e.g., "alice.example.com") 8 + // 9 + // Processing steps: 10 + // 1. require_session → SessionInfo { did } 11 + // 2. SELECT did FROM handles WHERE handle = ? → 404 HANDLE_NOT_FOUND if absent 12 + // 3. Verify session.did == handle_row.did → 403 FORBIDDEN if not owner 13 + // 4. If state.dns_provider is Some: call delete_record(name) → 502 DNS_ERROR on failure 14 + // (DNS deletion precedes DB deletion: a DB row without a DNS record is operator-fixable; 15 + // a DNS record without a DB row is an invisible orphan that can corrupt future 16 + // registrations for the same subdomain) 17 + // 5. DELETE FROM handles WHERE handle = ? 18 + // 6. Return 204 No Content 19 + // 20 + // Outputs (success): 204 No Content 21 + // Outputs (error): 401 UNAUTHORIZED, 403 FORBIDDEN, 404 HANDLE_NOT_FOUND, 22 + // 502 DNS_ERROR, 500 INTERNAL_ERROR 23 + 24 + use axum::{ 25 + extract::{Path, State}, 26 + http::{HeaderMap, StatusCode}, 27 + response::IntoResponse, 28 + }; 29 + 30 + use crate::app::AppState; 31 + use crate::routes::auth::require_session; 32 + use common::{ApiError, ErrorCode}; 33 + 34 + pub async fn delete_handle_handler( 35 + State(state): State<AppState>, 36 + headers: HeaderMap, 37 + Path(handle): Path<String>, 38 + ) -> Result<impl IntoResponse, ApiError> { 39 + // Step 1: Authenticate via session Bearer token. 40 + let session = require_session(&headers, &state.db).await?; 41 + 42 + // Step 2: Fetch the handle row; 404 if it does not exist. 43 + let row: Option<(String,)> = sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 44 + .bind(&handle) 45 + .fetch_optional(&state.db) 46 + .await 47 + .map_err(|e| { 48 + tracing::error!(error = %e, handle = %handle, "failed to fetch handle"); 49 + ApiError::new(ErrorCode::InternalError, "failed to look up handle") 50 + })?; 51 + 52 + let (owner_did,) = row.ok_or_else(|| ApiError::new(ErrorCode::HandleNotFound, "handle not found"))?; 53 + 54 + // Step 3: Verify ownership — session DID must match the handle owner. 55 + if session.did != owner_did { 56 + return Err(ApiError::new( 57 + ErrorCode::Forbidden, 58 + "you do not own this handle", 59 + )); 60 + } 61 + 62 + // Step 4: Delete DNS record before deleting the DB row. 63 + // DNS deletion precedes DB deletion: a DB row without a DNS record is operator-fixable 64 + // (admin can retry), whereas a DNS record without a DB row is an invisible orphan that 65 + // could corrupt a future handle registration for the same subdomain. 66 + if let Some(provider) = &state.dns_provider { 67 + let name = handle.split_once('.').map(|(n, _)| n).unwrap_or(&handle); 68 + provider.delete_record(name).await.map_err(|e| { 69 + tracing::error!( 70 + error = %e, 71 + handle = %handle, 72 + did = %session.did, 73 + "DNS record deletion failed" 74 + ); 75 + ApiError::new(ErrorCode::DnsError, "failed to delete DNS record") 76 + })?; 77 + } 78 + 79 + // Step 5: Delete the handle row. 80 + sqlx::query("DELETE FROM handles WHERE handle = ?") 81 + .bind(&handle) 82 + .execute(&state.db) 83 + .await 84 + .map_err(|e| { 85 + tracing::error!(error = %e, handle = %handle, "failed to delete handle row"); 86 + ApiError::new(ErrorCode::InternalError, "failed to delete handle") 87 + })?; 88 + 89 + // Step 6: Return 204 No Content. 90 + Ok(StatusCode::NO_CONTENT) 91 + } 92 + 93 + // ── Tests ──────────────────────────────────────────────────────────────────── 94 + 95 + #[cfg(test)] 96 + mod tests { 97 + use crate::app::test_state; 98 + use crate::routes::test_utils::seed_handle; 99 + use crate::routes::token::generate_token; 100 + use axum::{ 101 + body::Body, 102 + http::{Request, StatusCode}, 103 + }; 104 + use std::future::Future; 105 + use std::pin::Pin; 106 + use std::sync::Arc; 107 + use tower::ServiceExt; 108 + use uuid::Uuid; 109 + 110 + // ── DNS provider test doubles ────────────────────────────────────────────── 111 + 112 + struct AlwaysOkDns; 113 + struct AlwaysErrDns; 114 + 115 + impl crate::dns::DnsProvider for AlwaysOkDns { 116 + fn create_record<'a>( 117 + &'a self, 118 + _name: &'a str, 119 + _target: &'a str, 120 + ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 121 + Box::pin(async { Ok(()) }) 122 + } 123 + 124 + fn delete_record<'a>( 125 + &'a self, 126 + _name: &'a str, 127 + ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 128 + Box::pin(async { Ok(()) }) 129 + } 130 + } 131 + 132 + impl crate::dns::DnsProvider for AlwaysErrDns { 133 + fn create_record<'a>( 134 + &'a self, 135 + _name: &'a str, 136 + _target: &'a str, 137 + ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 138 + Box::pin(async { Err(crate::dns::DnsError("simulated provider error".to_string())) }) 139 + } 140 + 141 + fn delete_record<'a>( 142 + &'a self, 143 + _name: &'a str, 144 + ) -> Pin<Box<dyn Future<Output = Result<(), crate::dns::DnsError>> + Send + 'a>> { 145 + Box::pin(async { Err(crate::dns::DnsError("simulated provider error".to_string())) }) 146 + } 147 + } 148 + 149 + async fn state_with_ok_dns() -> crate::app::AppState { 150 + let base = test_state().await; 151 + crate::app::AppState { 152 + dns_provider: Some(Arc::new(AlwaysOkDns)), 153 + ..base 154 + } 155 + } 156 + 157 + async fn state_with_err_dns() -> crate::app::AppState { 158 + let base = test_state().await; 159 + crate::app::AppState { 160 + dns_provider: Some(Arc::new(AlwaysErrDns)), 161 + ..base 162 + } 163 + } 164 + 165 + // ── Test session helpers ─────────────────────────────────────────────────── 166 + 167 + /// Insert a session for an existing account row. Returns the plaintext token. 168 + async fn insert_session(db: &sqlx::SqlitePool, did: &str) -> String { 169 + let token = generate_token(); 170 + sqlx::query( 171 + "INSERT INTO sessions (id, did, device_id, token_hash, created_at, expires_at) \ 172 + VALUES (?, ?, NULL, ?, datetime('now'), datetime('now', '+1 year'))", 173 + ) 174 + .bind(Uuid::new_v4().to_string()) 175 + .bind(did) 176 + .bind(&token.hash) 177 + .execute(db) 178 + .await 179 + .expect("insert session"); 180 + token.plaintext 181 + } 182 + 183 + fn delete_handle_request(session_token: &str, handle: &str) -> Request<Body> { 184 + Request::builder() 185 + .method("DELETE") 186 + .uri(format!("/v1/handles/{handle}")) 187 + .header("Authorization", format!("Bearer {session_token}")) 188 + .body(Body::empty()) 189 + .unwrap() 190 + } 191 + 192 + // ── Happy path ───────────────────────────────────────────────────────────── 193 + 194 + /// Deleting an owned handle with no DNS provider removes the row and returns 204. 195 + #[tokio::test] 196 + async fn happy_path_deletes_handle_no_dns_provider() { 197 + let state = test_state().await; 198 + let db = state.db.clone(); 199 + let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 200 + let handle = format!("alice.{}", state.config.available_user_domains[0]); 201 + 202 + seed_handle(&db, &handle, &did).await; 203 + let token = insert_session(&db, &did).await; 204 + 205 + let app = crate::app::app(state); 206 + let response = app 207 + .oneshot(delete_handle_request(&token, &handle)) 208 + .await 209 + .unwrap(); 210 + 211 + assert_eq!(response.status(), StatusCode::NO_CONTENT); 212 + 213 + // Verify the row was removed. 214 + let row: Option<(String,)> = sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 215 + .bind(&handle) 216 + .fetch_optional(&db) 217 + .await 218 + .unwrap(); 219 + assert!(row.is_none(), "handle row must be removed after deletion"); 220 + } 221 + 222 + /// DNS provider succeeds: row is deleted and DNS is cleaned up; returns 204. 223 + #[tokio::test] 224 + async fn dns_provider_success_deletes_handle_and_dns() { 225 + let state = state_with_ok_dns().await; 226 + let db = state.db.clone(); 227 + let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 228 + let handle = format!("alice.{}", state.config.available_user_domains[0]); 229 + 230 + seed_handle(&db, &handle, &did).await; 231 + let token = insert_session(&db, &did).await; 232 + 233 + let app = crate::app::app(state); 234 + let response = app 235 + .oneshot(delete_handle_request(&token, &handle)) 236 + .await 237 + .unwrap(); 238 + 239 + assert_eq!(response.status(), StatusCode::NO_CONTENT); 240 + 241 + let row: Option<(String,)> = sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 242 + .bind(&handle) 243 + .fetch_optional(&db) 244 + .await 245 + .unwrap(); 246 + assert!(row.is_none(), "handle row must be removed when DNS succeeds"); 247 + } 248 + 249 + /// DNS provider fails: returns 502 DNS_ERROR and the DB row is NOT deleted. 250 + #[tokio::test] 251 + async fn dns_provider_failure_returns_502_and_row_survives() { 252 + let state = state_with_err_dns().await; 253 + let db = state.db.clone(); 254 + let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 255 + let handle = format!("alice.{}", state.config.available_user_domains[0]); 256 + 257 + seed_handle(&db, &handle, &did).await; 258 + let token = insert_session(&db, &did).await; 259 + 260 + let app = crate::app::app(state); 261 + let response = app 262 + .oneshot(delete_handle_request(&token, &handle)) 263 + .await 264 + .unwrap(); 265 + 266 + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 267 + let body: serde_json::Value = serde_json::from_slice( 268 + &axum::body::to_bytes(response.into_body(), usize::MAX) 269 + .await 270 + .unwrap(), 271 + ) 272 + .unwrap(); 273 + assert_eq!(body["error"]["code"], "DNS_ERROR"); 274 + 275 + // DNS precedes DB: the row must still exist when DNS deletion fails. 276 + let row: Option<(String,)> = sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 277 + .bind(&handle) 278 + .fetch_optional(&db) 279 + .await 280 + .unwrap(); 281 + assert!( 282 + row.is_some(), 283 + "handle row must survive when DNS deletion fails" 284 + ); 285 + } 286 + 287 + // ── Auth failures ────────────────────────────────────────────────────────── 288 + 289 + /// Missing Authorization header returns 401. 290 + #[tokio::test] 291 + async fn missing_auth_returns_401() { 292 + let state = test_state().await; 293 + let db = state.db.clone(); 294 + let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 295 + let handle = format!("alice.{}", state.config.available_user_domains[0]); 296 + seed_handle(&db, &handle, &did).await; 297 + 298 + let request = Request::builder() 299 + .method("DELETE") 300 + .uri(format!("/v1/handles/{handle}")) 301 + .body(Body::empty()) 302 + .unwrap(); 303 + 304 + let app = crate::app::app(state); 305 + let response = app.oneshot(request).await.unwrap(); 306 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 307 + } 308 + 309 + // ── Authorization (ownership) ───────────────────────────────────────────── 310 + 311 + /// Session DID that does not own the handle returns 403. 312 + #[tokio::test] 313 + async fn non_owner_session_returns_403() { 314 + let state = test_state().await; 315 + let db = state.db.clone(); 316 + 317 + // Owner account + handle. 318 + let owner_did = 319 + format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 320 + let handle = format!("alice.{}", state.config.available_user_domains[0]); 321 + seed_handle(&db, &handle, &owner_did).await; 322 + 323 + // Different account that tries to delete the handle. 324 + let other_did = 325 + format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 326 + sqlx::query( 327 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 328 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 329 + ) 330 + .bind(&other_did) 331 + .bind(format!("{other_did}@test.example.com")) 332 + .execute(&db) 333 + .await 334 + .unwrap(); 335 + let other_token = insert_session(&db, &other_did).await; 336 + 337 + let app = crate::app::app(state); 338 + let response = app 339 + .oneshot(delete_handle_request(&other_token, &handle)) 340 + .await 341 + .unwrap(); 342 + 343 + assert_eq!(response.status(), StatusCode::FORBIDDEN); 344 + let body: serde_json::Value = serde_json::from_slice( 345 + &axum::body::to_bytes(response.into_body(), usize::MAX) 346 + .await 347 + .unwrap(), 348 + ) 349 + .unwrap(); 350 + assert_eq!(body["error"]["code"], "FORBIDDEN"); 351 + } 352 + 353 + // ── Not found ───────────────────────────────────────────────────────────── 354 + 355 + /// Deleting a handle that doesn't exist returns 404. 356 + #[tokio::test] 357 + async fn nonexistent_handle_returns_404() { 358 + let state = test_state().await; 359 + let db = state.db.clone(); 360 + let did = format!("did:plc:{}", &Uuid::new_v4().to_string().replace('-', "")[..24]); 361 + 362 + sqlx::query( 363 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 364 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 365 + ) 366 + .bind(&did) 367 + .bind(format!("{did}@test.example.com")) 368 + .execute(&db) 369 + .await 370 + .unwrap(); 371 + let token = insert_session(&db, &did).await; 372 + 373 + let app = crate::app::app(state); 374 + let response = app 375 + .oneshot(delete_handle_request(&token, "ghost.test.example.com")) 376 + .await 377 + .unwrap(); 378 + 379 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 380 + let body: serde_json::Value = serde_json::from_slice( 381 + &axum::body::to_bytes(response.into_body(), usize::MAX) 382 + .await 383 + .unwrap(), 384 + ) 385 + .unwrap(); 386 + assert_eq!(body["error"]["code"], "HANDLE_NOT_FOUND"); 387 + } 388 + }
+1
crates/relay/src/routes/mod.rs
··· 5 5 pub mod create_did; 6 6 pub mod create_handle; 7 7 pub mod create_mobile_account; 8 + pub mod delete_handle; 8 9 pub mod create_session; 9 10 pub mod create_signing_key; 10 11 pub mod delete_session;