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(identity-wallet): implement submit_recovery_override and register Tauri commands

- Add submit_recovery_override function that POSTs signed operation to plc.directory
- Re-fetches audit log and DID document to update Keychain cache
- Add build_recovery_override_cmd Tauri command wrapper
- Add submit_recovery_override_cmd Tauri command wrapper
- Register both commands in generate_handler\! macro
- Add test for SignedRecoveryOp serialization with camelCase
- Verifies AC7.4: Recovery override submission to plc.directory

Malpercio 11b1707d d1bd03e1

+168 -1
+2
apps/identity-wallet/src-tauri/src/lib.rs
··· 819 819 claim::sign_and_verify_claim, 820 820 claim::submit_claim, 821 821 plc_monitor::check_identity_status, 822 + recovery::build_recovery_override_cmd, 823 + recovery::submit_recovery_override_cmd, 822 824 ]) 823 825 .run(tauri::generate_context!()) 824 826 .expect("error while running tauri application");
+10
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 172 172 } 173 173 } 174 174 175 + /// Returns the plc.directory base URL. 176 + pub fn plc_directory_url(&self) -> &str { 177 + &self.plc_directory_url 178 + } 179 + 180 + /// Returns a reference to the inner HTTP client. 181 + pub fn client(&self) -> &Client { 182 + &self.client 183 + } 184 + 175 185 /// Resolve a handle to a DID via DNS TXT lookup with HTTP fallback. 176 186 /// 177 187 /// Attempts DNS TXT lookup for `_atproto.{handle}` first, then falls back to HTTP
+156 -1
apps/identity-wallet/src-tauri/src/recovery.rs
··· 3 3 // Functional Core: Types and error enums for recovery override operations 4 4 // Imperative Shell: Recovery override building and submission commands (in later phases) 5 5 6 - use crate::claim::{ChangeType, OpDiff, ServiceChange}; 6 + use crate::claim::{ChangeType, ClaimResult, OpDiff, ServiceChange}; 7 7 use crate::identity_store::IdentityStore; 8 8 use crate::pds_client::PdsClient; 9 9 use chrono::{DateTime, Duration, Utc}; ··· 330 330 }) 331 331 } 332 332 333 + /// Submits the pending recovery override operation to plc.directory. 334 + /// 335 + /// Reads the signed op from RecoveryState (set by build_recovery_override), 336 + /// POSTs it to plc.directory, and updates the cached PLC audit log. 337 + pub async fn submit_recovery_override( 338 + pds_client: &PdsClient, 339 + did: &str, 340 + signed_op: &serde_json::Value, 341 + ) -> Result<ClaimResult, RecoveryError> { 342 + let store = IdentityStore; 343 + 344 + // 1. POST the signed operation to plc.directory. 345 + pds_client 346 + .post_plc_operation(did, signed_op) 347 + .await 348 + .map_err(|e| RecoveryError::PlcDirectoryError { 349 + message: format!("PLC directory rejected the operation: {e}"), 350 + })?; 351 + 352 + // 2. Re-fetch the audit log to update the cache. 353 + let updated_log = pds_client 354 + .fetch_audit_log(did) 355 + .await 356 + .map_err(|e| RecoveryError::NetworkError { 357 + message: format!("Failed to fetch updated audit log: {e}"), 358 + })?; 359 + 360 + store 361 + .store_plc_log(did, &updated_log) 362 + .map_err(|e| RecoveryError::NetworkError { 363 + message: format!("Failed to update cached log: {e}"), 364 + })?; 365 + 366 + // 3. Re-fetch the DID document (it should now reflect the recovered state). 367 + // Use the raw plc.directory endpoint, not the audit log. 368 + let did_doc_url = format!("{}/{}", pds_client.plc_directory_url(), did); 369 + let did_doc: serde_json::Value = pds_client 370 + .client() 371 + .get(&did_doc_url) 372 + .send() 373 + .await 374 + .map_err(|e| RecoveryError::NetworkError { 375 + message: format!("Failed to fetch DID document: {e}"), 376 + })? 377 + .json() 378 + .await 379 + .map_err(|e| RecoveryError::NetworkError { 380 + message: format!("Failed to parse DID document: {e}"), 381 + })?; 382 + 383 + store 384 + .store_did_doc(did, &serde_json::to_string(&did_doc).unwrap_or_default()) 385 + .map_err(|e| RecoveryError::NetworkError { 386 + message: format!("Failed to update cached DID doc: {e}"), 387 + })?; 388 + 389 + Ok(ClaimResult { 390 + updated_did_doc: did_doc, 391 + }) 392 + } 393 + 394 + /// Tauri command: Build a recovery override operation. 395 + /// 396 + /// Stores the built operation in RecoveryState for subsequent submission. 397 + #[tauri::command] 398 + pub async fn build_recovery_override_cmd( 399 + state: tauri::State<'_, crate::oauth::AppState>, 400 + did: String, 401 + operation_cid: String, 402 + ) -> Result<SignedRecoveryOp, RecoveryError> { 403 + let result = build_recovery_override( 404 + state.pds_client(), 405 + &did, 406 + &operation_cid, 407 + ) 408 + .await?; 409 + 410 + // Store in RecoveryState for submit_recovery_override_cmd. 411 + let mut recovery = state.recovery_state.lock().await; 412 + *recovery = Some(RecoveryState { 413 + did: did.clone(), 414 + signed_op: result.signed_op.clone(), 415 + }); 416 + 417 + Ok(result) 418 + } 419 + 420 + /// Tauri command: Submit the pending recovery override to plc.directory. 421 + #[tauri::command] 422 + pub async fn submit_recovery_override_cmd( 423 + state: tauri::State<'_, crate::oauth::AppState>, 424 + did: String, 425 + ) -> Result<ClaimResult, RecoveryError> { 426 + let recovery = state.recovery_state.lock().await; 427 + let recovery_state = recovery.as_ref().ok_or(RecoveryError::SigningFailed { 428 + message: "No pending recovery operation. Call build_recovery_override first.".to_string(), 429 + })?; 430 + 431 + if recovery_state.did != did { 432 + return Err(RecoveryError::SigningFailed { 433 + message: format!( 434 + "Recovery state DID mismatch: expected {}, got {}", 435 + recovery_state.did, did 436 + ), 437 + }); 438 + } 439 + 440 + let signed_op = recovery_state.signed_op.clone(); 441 + drop(recovery); // Release lock before network calls. 442 + 443 + let result = submit_recovery_override( 444 + state.pds_client(), 445 + &did, 446 + &signed_op, 447 + ) 448 + .await?; 449 + 450 + // Clear recovery state on success. 451 + let mut recovery = state.recovery_state.lock().await; 452 + *recovery = None; 453 + 454 + Ok(result) 455 + } 456 + 333 457 #[cfg(test)] 334 458 mod tests { 335 459 use super::*; ··· 776 900 assert_eq!( 777 901 fork_entry.cid, "bafy_genesis", 778 902 "Should find earliest fork point (genesis), not first unauthorized op" 903 + ); 904 + } 905 + 906 + /// AC7.4: SignedRecoveryOp serializes correctly with camelCase 907 + #[test] 908 + fn test_ac7_4_signed_recovery_op_serializes_camel_case() { 909 + let signed_op = SignedRecoveryOp { 910 + diff: OpDiff { 911 + added_keys: vec!["did:key:z6MkhaXgBZDvotzL".to_string()], 912 + removed_keys: vec![], 913 + changed_services: vec![], 914 + prev_cid: Some("bafy_cid".to_string()), 915 + }, 916 + signed_op: serde_json::json!({ 917 + "type": "plc_operation", 918 + "sig": "test_sig" 919 + }), 920 + }; 921 + 922 + let json = serde_json::to_value(&signed_op).expect("serialize"); 923 + 924 + // Verify camelCase serialization: "signed_op" -> "signedOp" 925 + assert!( 926 + json.get("signedOp").is_some(), 927 + "signed_op should be serialized as signedOp" 928 + ); 929 + 930 + // Verify the diff is included 931 + assert!( 932 + json.get("diff").is_some(), 933 + "diff should be present" 779 934 ); 780 935 } 781 936 }