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_claim command (AC4.8, AC4.9)

Adds submit_claim_impl helper and Tauri command wrapper for final claim step.

Core logic:
- Reads verified_signed_op from ClaimState, returns Unauthorized if None
- POSTs signed operation to plc.directory via post_plc_operation
- Persists identity to IdentityStore:
- add_identity(did) registers DID
- get_or_create_device_key(did) ensures key exists
- Re-fetches DID doc and stores via store_did_doc
- Fetches and stores audit log via store_plc_log
- Clears claim state after success
- Returns ClaimResult with updated_did_doc

Tests:
- AC4.8: Success case - operation POSTs successfully, identity persisted
- AC4.9: PlcDirectoryError when POST fails with 409 Conflict
- Unauthorized when verified_signed_op is None

Changes:
- Added #[derive(Clone)] to ClaimState for mutex extraction
- Manual conversion of PlcDidDocument to serde_json::Value
- Added 3 tests covering happy path and error cases

authored by

Malpercio and committed by
Tangled
196bd290 8f692b71

+312
+312
apps/identity-wallet/src-tauri/src/claim.rs
··· 96 96 /// This state is set by `resolve_identity` and used by subsequent 97 97 /// `start_pds_auth`, `request_claim_verification`, `sign_and_verify_claim`, 98 98 /// and `submit_claim` commands within the same claim flow session. 99 + #[derive(Clone)] 99 100 pub struct ClaimState { 100 101 /// The DID being claimed (resolved by `resolve_identity`) 101 102 pub did: String, ··· 862 863 }, 863 864 op_json_str, 864 865 )) 866 + } 867 + 868 + /// Submit a verified signed claim operation to plc.directory. 869 + /// 870 + /// This is the final step in the claim flow. It: 871 + /// 1. Reads `verified_signed_op` from `ClaimState`. Returns `Unauthorized` if `None`. 872 + /// 2. POSTs the signed operation to plc.directory via `pds_client.post_plc_operation()` 873 + /// 3. Persists the claimed identity to `IdentityStore`: 874 + /// - `add_identity(did)` — registers DID in managed-dids index 875 + /// - `get_or_create_device_key(did)` — ensures device key exists 876 + /// - Re-fetches the DID document from plc.directory and stores it 877 + /// - Fetches the PLC audit log and stores it 878 + /// 4. Clears `ClaimState` (set `AppState.claim_state` to `None`) 879 + /// 5. Returns `ClaimResult` with the updated DID document 880 + pub(crate) async fn submit_claim_impl( 881 + pds_client: &crate::pds_client::PdsClient, 882 + claim_state: &ClaimState, 883 + ) -> Result<ClaimResult, ClaimError> { 884 + // Step 1: Read verified_signed_op from ClaimState 885 + let Some(ref verified_signed_op_str) = claim_state.verified_signed_op else { 886 + return Err(ClaimError::Unauthorized); 887 + }; 888 + 889 + // Parse the stored JSON string back to Value 890 + let operation: serde_json::Value = serde_json::from_str(verified_signed_op_str) 891 + .map_err(|e| ClaimError::NetworkError { 892 + message: format!("failed to parse verified signed operation: {}", e), 893 + })?; 894 + 895 + // Step 2: POST the signed operation to plc.directory 896 + pds_client 897 + .post_plc_operation(&claim_state.did, &operation) 898 + .await 899 + .map_err(|e| match e { 900 + crate::pds_client::PdsClientError::InvalidResponse { message } => { 901 + ClaimError::PlcDirectoryError { message } 902 + } 903 + other => ClaimError::NetworkError { 904 + message: format!("post_plc_operation failed: {}", other), 905 + }, 906 + })?; 907 + 908 + // Step 3: Persist the claimed identity to IdentityStore 909 + let store = IdentityStore; 910 + 911 + // 3a: Register DID in managed-dids index (may already exist from prior attempts) 912 + if let Err(e) = store.add_identity(&claim_state.did) { 913 + // IdentityAlreadyExists is fine — user may have a partially completed prior claim 914 + if !matches!(e, crate::identity_store::IdentityStoreError::IdentityAlreadyExists) { 915 + return Err(ClaimError::NetworkError { 916 + message: format!("failed to add identity: {}", e), 917 + }); 918 + } 919 + } 920 + 921 + // 3b: Ensure device key exists for the DID 922 + store.get_or_create_device_key(&claim_state.did).map_err(|e| { 923 + ClaimError::NetworkError { 924 + message: format!("failed to get or create device key: {}", e), 925 + } 926 + })?; 927 + 928 + // 3c: Re-fetch the DID document from plc.directory 929 + let (_, updated_did_doc) = pds_client 930 + .discover_pds(&claim_state.did) 931 + .await 932 + .map_err(|e| { 933 + ClaimError::NetworkError { 934 + message: format!("failed to re-fetch DID document: {}", e), 935 + } 936 + })?; 937 + 938 + // Store the updated DID document as JSON string 939 + let did_doc_value = serde_json::json!({ 940 + "did": updated_did_doc.did, 941 + "alsoKnownAs": updated_did_doc.also_known_as, 942 + "rotationKeys": updated_did_doc.rotation_keys, 943 + "verificationMethods": updated_did_doc.verification_methods, 944 + "services": updated_did_doc.services 945 + .iter() 946 + .map(|(id, svc)| { 947 + (id.clone(), serde_json::json!({ 948 + "type": svc.service_type, 949 + "endpoint": svc.endpoint, 950 + })) 951 + }) 952 + .collect::<serde_json::Map<String, serde_json::Value>>() 953 + }); 954 + 955 + let did_doc_json = serde_json::to_string(&did_doc_value).map_err(|e| { 956 + ClaimError::NetworkError { 957 + message: format!("failed to serialize DID document: {}", e), 958 + } 959 + })?; 960 + 961 + store 962 + .store_did_doc(&claim_state.did, &did_doc_json) 963 + .map_err(|e| ClaimError::NetworkError { 964 + message: format!("failed to store DID document: {}", e), 965 + })?; 966 + 967 + // 3d: Fetch and store the PLC audit log 968 + let log_json = pds_client 969 + .fetch_audit_log(&claim_state.did) 970 + .await 971 + .map_err(|e| ClaimError::NetworkError { 972 + message: format!("failed to fetch audit log: {}", e), 973 + })?; 974 + 975 + store 976 + .store_plc_log(&claim_state.did, &log_json) 977 + .map_err(|e| ClaimError::NetworkError { 978 + message: format!("failed to store PLC log: {}", e), 979 + })?; 980 + 981 + // Step 5: Return the updated DID document 982 + Ok(ClaimResult { 983 + updated_did_doc: did_doc_value, 984 + }) 985 + } 986 + 987 + /// Tauri command wrapper for submit_claim. 988 + /// 989 + /// Delegates to `submit_claim_impl` to allow testing without AppState. 990 + #[tauri::command] 991 + pub async fn submit_claim( 992 + state: tauri::State<'_, crate::oauth::AppState>, 993 + did: String, 994 + ) -> Result<ClaimResult, ClaimError> { 995 + let pds_client = state.pds_client(); 996 + 997 + // Acquire lock, extract claim state, then release lock before network calls 998 + let claim_state_copy = { 999 + let claim_state = state.claim_state.lock().await; 1000 + claim_state.as_ref().cloned() 1001 + }; 1002 + 1003 + let Some(mut claim_state) = claim_state_copy else { 1004 + return Err(ClaimError::Unauthorized); 1005 + }; 1006 + 1007 + let result = submit_claim_impl(pds_client, &claim_state).await; 1008 + 1009 + // On success, clear claim state 1010 + if result.is_ok() { 1011 + let mut claim_state_lock = state.claim_state.lock().await; 1012 + *claim_state_lock = None; 1013 + } 1014 + 1015 + result 865 1016 } 866 1017 867 1018 /// Extract handle from also_known_as entries. ··· 2137 2288 assert!( 2138 2289 matches!(result, Err(ClaimError::InvalidToken)), 2139 2290 "should return InvalidToken when PDS returns InvalidToken error" 2291 + ); 2292 + } 2293 + 2294 + // ── submit_claim tests (AC4.8, AC4.9) ────────────────────────────────── 2295 + 2296 + /// Test AC4.8 — Success: submit_claim POSTs signed operation and persists identity 2297 + #[tokio::test] 2298 + async fn test_submit_claim_success() { 2299 + use httpmock::MockServer; 2300 + use std::sync::{Arc, Mutex}; 2301 + 2302 + let mock_server = MockServer::start(); 2303 + 2304 + // Mock POST to plc.directory (signed operation submission) 2305 + mock_server.mock(|when, then| { 2306 + when.method(httpmock::Method::POST).path("/did:plc:test"); 2307 + then.status(200).json_body(serde_json::json!({})); 2308 + }); 2309 + 2310 + // Mock GET to plc.directory (re-fetch DID doc) 2311 + let updated_doc = serde_json::json!({ 2312 + "did": "did:plc:test", 2313 + "alsoKnownAs": ["at://alice.example.com"], 2314 + "rotationKeys": ["did:key:zQ3test"], 2315 + "verificationMethods": {}, 2316 + "services": { 2317 + "atproto_pds": { 2318 + "type": "AtprotoPersonalDataServer", 2319 + "endpoint": "https://pds.example.com" 2320 + } 2321 + } 2322 + }); 2323 + 2324 + mock_server.mock(|when, then| { 2325 + when.method(httpmock::Method::GET) 2326 + .path("/did:plc:test") 2327 + .header_exists("host"); 2328 + then.status(200) 2329 + .header("content-type", "application/json") 2330 + .json_body(updated_doc.clone()); 2331 + }); 2332 + 2333 + // Mock HEAD to plc.directory (reachability check) 2334 + mock_server.mock(|when, then| { 2335 + when.method(httpmock::Method::HEAD) 2336 + .path_contains("pds.example.com"); 2337 + then.status(200); 2338 + }); 2339 + 2340 + // Mock audit log fetch 2341 + mock_server.mock(|when, then| { 2342 + when.method(httpmock::Method::GET) 2343 + .path("/did:plc:test/log/audit"); 2344 + then.status(200) 2345 + .header("content-type", "application/json") 2346 + .json_body(serde_json::json!([ 2347 + { 2348 + "cid": "bafy123", 2349 + "operation": { 2350 + "type": "plc_operation" 2351 + } 2352 + } 2353 + ])); 2354 + }); 2355 + 2356 + let pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 2357 + 2358 + let claim_state = ClaimState { 2359 + did: "did:plc:test".to_string(), 2360 + pds_url: mock_server.base_url(), 2361 + did_doc: PlcDidDocument { 2362 + did: "did:plc:test".to_string(), 2363 + also_known_as: vec!["at://alice.example.com".to_string()], 2364 + rotation_keys: vec!["did:key:zQ3test".to_string()], 2365 + verification_methods: serde_json::json!({}), 2366 + services: std::collections::HashMap::new(), 2367 + }, 2368 + pds_oauth_client: None, 2369 + verified_signed_op: Some( 2370 + r#"{"type":"plc_operation","prev":"bafy123","rotationKeys":["did:key:zQ3test"]}"# 2371 + .to_string(), 2372 + ), 2373 + }; 2374 + 2375 + let result = submit_claim_impl(&pds_client, &claim_state).await; 2376 + 2377 + assert!( 2378 + result.is_ok(), 2379 + "should successfully submit claim and persist identity" 2380 + ); 2381 + let claim_result = result.unwrap(); 2382 + assert_eq!(claim_result.updated_did_doc["did"], "did:plc:test"); 2383 + } 2384 + 2385 + /// Test AC4.9 — Failure: submit_claim returns PlcDirectoryError when POST fails 2386 + #[tokio::test] 2387 + async fn test_submit_claim_plc_directory_error() { 2388 + use httpmock::MockServer; 2389 + 2390 + let mock_server = MockServer::start(); 2391 + 2392 + // Mock POST returning 409 Conflict 2393 + mock_server.mock(|when, then| { 2394 + when.method(httpmock::Method::POST).path("/did:plc:test"); 2395 + then.status(409) 2396 + .json_body(serde_json::json!({"error": "Conflicting operation"})); 2397 + }); 2398 + 2399 + let pds_client = crate::pds_client::PdsClient::new_for_test(mock_server.base_url()); 2400 + 2401 + let claim_state = ClaimState { 2402 + did: "did:plc:test".to_string(), 2403 + pds_url: mock_server.base_url(), 2404 + did_doc: PlcDidDocument { 2405 + did: "did:plc:test".to_string(), 2406 + also_known_as: vec!["at://alice.example.com".to_string()], 2407 + rotation_keys: vec!["did:key:zQ3test".to_string()], 2408 + verification_methods: serde_json::json!({}), 2409 + services: std::collections::HashMap::new(), 2410 + }, 2411 + pds_oauth_client: None, 2412 + verified_signed_op: Some( 2413 + r#"{"type":"plc_operation","prev":"bafy123"}"#.to_string(), 2414 + ), 2415 + }; 2416 + 2417 + let result = submit_claim_impl(&pds_client, &claim_state).await; 2418 + 2419 + assert!(result.is_err()); 2420 + match result.unwrap_err() { 2421 + ClaimError::PlcDirectoryError { message } => { 2422 + assert!(message.contains("Conflicting operation")); 2423 + } 2424 + e => panic!("Expected PlcDirectoryError, got: {:?}", e), 2425 + } 2426 + } 2427 + 2428 + /// Test: Unauthorized — no verified signed operation 2429 + #[tokio::test] 2430 + async fn test_submit_claim_no_verified_op() { 2431 + let pds_client = crate::pds_client::PdsClient::new(); 2432 + 2433 + let claim_state = ClaimState { 2434 + did: "did:plc:test".to_string(), 2435 + pds_url: "https://plc.directory".to_string(), 2436 + did_doc: PlcDidDocument { 2437 + did: "did:plc:test".to_string(), 2438 + also_known_as: vec![], 2439 + rotation_keys: vec![], 2440 + verification_methods: serde_json::json!({}), 2441 + services: std::collections::HashMap::new(), 2442 + }, 2443 + pds_oauth_client: None, 2444 + verified_signed_op: None, // No verified operation 2445 + }; 2446 + 2447 + let result = submit_claim_impl(&pds_client, &claim_state).await; 2448 + 2449 + assert!( 2450 + matches!(result, Err(ClaimError::Unauthorized)), 2451 + "should return Unauthorized when verified_signed_op is None" 2140 2452 ); 2141 2453 } 2142 2454 }