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.

fix(identity-wallet): address PLC key management claim flow PR review feedback

## CRITICAL ISSUES FIXED

### 1. sign_and_verify_claim missing DID validation
- Added defense-in-depth check comparing caller's DID to ClaimState.did
- Prevents unauthorized access to claims

### 2. start_pds_auth missing pds_url validation
- Added validation that pds_url matches ClaimState.pds_url
- Prevents authentication with wrong PDS endpoint

### 3. AC/ticket references removed from source code
- Removed 17 occurrences of AC4.x references from test comments
- Replaced with descriptive text per CLAUDE.md policy

## IMPORTANT ISSUES FIXED

### 4. device_key_is_root error handling
- Added tracing::error\! logging at error branches
- Logs failures in list_identities and get_or_create_device_key

### 5. Silently discarded results when ClaimState cleared
- start_pds_auth now returns Unauthorized if ClaimState becomes None during store
- sign_and_verify_claim now returns Unauthorized if ClaimState becomes None during store

### 6. Token exchange error message details
- Included HTTP error body in fallthrough error message
- Changed from generic 'token exchange failed' to 'token exchange returned non-success response: {body}'

### 7. Token detection string matching documentation
- Added comment documenting the fragile string matching for use_dpop_nonce detection
- Notes that server error format changes will cause silent detection failure

### 8. ServiceChange.change_type now uses enum
- Created ChangeType enum with Added, Removed, Modified variants
- Updated ServiceChange to use ChangeType instead of String
- Serializes as camelCase per serde(rename_all = "camelCase")

### 9. Signed operation now uses serde_json::Value
- Changed VerifiedClaimOp.signed_op from String to serde_json::Value
- Changed ClaimState.verified_signed_op from Option<String> to Option<serde_json::Value>
- Eliminated unnecessary serialization/deserialization round-trips
- Updated both Rust and TypeScript types

### 13. OpDiff.prev_cid now uses Option<String>
- Changed from String with unwrap_or_default() to Option<String>
- Correctly represents absence of prior operation
- Updated TypeScript type to string | null

### 14. Error body handling in pds_client.rs
- Replaced 5 occurrences of unwrap_or_default() with unwrap_or_else
- Provides informative fallback '(response body unreadable)' on read errors

### 16. submit_claim_impl documentation
- Updated doc comment to clarify caller is responsible for clearing ClaimState
- Fixed reference to 'verify_claim' -> 'sign_and_verify_claim'

## SUGGESTIONS IMPLEMENTED

### 10. TypeScript claim flow JSDoc comments
- Added comprehensive JSDoc comments to all claim types
- Added detailed comments to claim command wrappers
- Added ChangeType union type definition

### 11. Logging added to claim commands
- Added tracing::info\! at entry point for all 5 claim commands
- Added tracing::warn\! for critical authorization failures

## VERIFICATION

- cargo check -p identity-wallet: PASS
- cargo fmt -p identity-wallet: PASS
- cargo clippy -p identity-wallet -- -D warnings: PASS
- TypeScript type check: PASS

All changes maintain backward compatibility with existing interfaces while
improving error handling, logging, documentation, and type safety.

authored by

Malpercio and committed by
Tangled
0004a815 3ccf46cf

+232 -66
+111 -58
apps/identity-wallet/src-tauri/src/claim.rs
··· 43 43 44 44 /// Verified claim operation ready for submission. 45 45 /// 46 - /// Returned by `verify_claim` command. 46 + /// Returned by `sign_and_verify_claim` command. 47 47 #[derive(Debug, Serialize, Clone)] 48 48 #[serde(rename_all = "camelCase")] 49 49 pub struct VerifiedClaimOp { 50 50 /// Diff of keys and services between current DID doc and proposed operation 51 51 pub diff: OpDiff, 52 - /// Signed operation (ready for PLC submission) 53 - pub signed_op: String, 52 + /// Signed operation (ready for PLC submission) as JSON value 53 + pub signed_op: serde_json::Value, 54 54 /// Warnings from verification (e.g., "This operation will break X") 55 55 pub warnings: Vec<String>, 56 56 } ··· 65 65 pub removed_keys: Vec<String>, 66 66 /// Service endpoint changes (added/removed/modified) 67 67 pub changed_services: Vec<ServiceChange>, 68 - /// Previous CID (content identifier) of the DID document 69 - pub prev_cid: String, 68 + /// Previous CID (content identifier) of the DID document (None if no prior operation) 69 + pub prev_cid: Option<String>, 70 + } 71 + 72 + /// Type of change to a service endpoint. 73 + #[derive(Debug, Serialize, Clone)] 74 + #[serde(rename_all = "camelCase")] 75 + pub enum ChangeType { 76 + /// Service endpoint was added 77 + Added, 78 + /// Service endpoint was removed 79 + Removed, 80 + /// Service endpoint was modified 81 + Modified, 70 82 } 71 83 72 84 /// Change to a service endpoint in the DID document. ··· 75 87 pub struct ServiceChange { 76 88 /// Service ID (e.g., "atproto_pds") 77 89 pub id: String, 78 - /// Type of change: "added", "removed", or "modified" 79 - pub change_type: String, 90 + /// Type of change: added, removed, or modified 91 + pub change_type: ChangeType, 80 92 /// Old endpoint URL (None if added) 81 93 pub old_endpoint: Option<String>, 82 94 /// New endpoint URL (None if removed) ··· 110 122 /// Wrapped in Arc to allow cloning out of the Mutex without holding the lock 111 123 /// across the network call in `request_claim_verification`. 112 124 pub pds_oauth_client: Option<std::sync::Arc<OAuthClient>>, 113 - /// Verified signed operation (set after `sign_and_verify_claim` succeeds) 114 - pub verified_signed_op: Option<String>, 125 + /// Verified signed operation (set after `sign_and_verify_claim` succeeds) as JSON value 126 + pub verified_signed_op: Option<serde_json::Value>, 115 127 } 116 128 117 129 // ── Error types ──────────────────────────────────────────────────────────── ··· 179 191 state: tauri::State<'_, crate::oauth::AppState>, 180 192 handle_or_did: String, 181 193 ) -> Result<IdentityInfo, ResolveError> { 194 + tracing::info!("resolve_identity command: resolving {}", handle_or_did); 182 195 let pds_client = state.pds_client(); 183 196 184 197 // Determine if input is a DID or handle ··· 229 242 .map(|first_key| device_key.multibase == *first_key) 230 243 .unwrap_or(false) 231 244 } 232 - Err(_) => false, // Key generation failed, assume not root 245 + Err(e) => { 246 + tracing::error!(error = %e, did = %did, "failed to get or create device key"); 247 + false 248 + } 233 249 } 234 250 } else { 235 251 false // DID not registered 236 252 } 237 253 } 238 - Err(_) => false, // Store lookup failed, assume not root 254 + Err(e) => { 255 + tracing::error!(error = %e, "failed to list identities"); 256 + false 257 + } 239 258 } 240 259 }; 241 260 ··· 303 322 state: tauri::State<'_, crate::oauth::AppState>, 304 323 pds_url: String, 305 324 ) -> Result<(), ClaimError> { 325 + tracing::info!("start_pds_auth command: authenticating with {}", pds_url); 306 326 use tauri_plugin_opener::OpenerExt; 307 327 308 - // 1. Validate ClaimState is populated 309 - let claim_state = state.claim_state.lock().await; 310 - let Some(claim) = claim_state.as_ref() else { 311 - drop(claim_state); 312 - return Err(ClaimError::Unauthorized); 313 - }; 328 + // 1. Validate ClaimState is populated and pds_url matches 329 + let did = { 330 + let claim_state = state.claim_state.lock().await; 331 + let Some(claim) = claim_state.as_ref() else { 332 + tracing::warn!("start_pds_auth: ClaimState not found"); 333 + return Err(ClaimError::Unauthorized); 334 + }; 314 335 315 - let did = claim.did.clone(); 316 - drop(claim_state); 336 + // Validate that pds_url matches ClaimState.pds_url (defense-in-depth) 337 + if claim.pds_url != pds_url { 338 + let expected = claim.pds_url.clone(); 339 + drop(claim_state); 340 + tracing::warn!( 341 + expected = %expected, 342 + received = %pds_url, 343 + "start_pds_auth: pds_url mismatch" 344 + ); 345 + return Err(ClaimError::Unauthorized); 346 + } 347 + 348 + claim.did.clone() 349 + }; // claim_state lock released here 317 350 318 351 let pds_client = state.pds_client(); 319 352 ··· 417 450 })); 418 451 419 452 let oauth_client = 420 - OAuthClient::new(session, pds_url).map_err(|_| ClaimError::NetworkError { 453 + OAuthClient::new(session, pds_url.clone()).map_err(|_| ClaimError::NetworkError { 421 454 message: "failed to create OAuth client".to_string(), 422 455 })?; 423 456 424 457 let mut claim_state = state.claim_state.lock().await; 425 458 if let Some(ref mut claim) = claim_state.as_mut() { 426 459 claim.pds_oauth_client = Some(std::sync::Arc::new(oauth_client)); 460 + } else { 461 + drop(claim_state); 462 + return Err(ClaimError::Unauthorized); 427 463 } 428 464 drop(claim_state); 429 465 ··· 487 523 488 524 let error_body = resp.text().await.unwrap_or_else(|_| "{}".to_string()); 489 525 526 + // Detect nonce retry by checking error JSON for "use_dpop_nonce" error code. 527 + // This is fragile string matching based on PDS/OAuth server error responses. 528 + // If the server's error format changes, this detection will fail silently. 490 529 if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_body) { 491 530 if error_json.get("error").and_then(|v| v.as_str()) == Some("use_dpop_nonce") { 492 531 if let Some(nonce_val) = nonce { ··· 533 572 } 534 573 535 574 Err(ClaimError::NetworkError { 536 - message: "token exchange failed".to_string(), 575 + message: format!( 576 + "token exchange returned non-success response: {}", 577 + error_body 578 + ), 537 579 }) 538 580 } 539 581 ··· 552 594 state: tauri::State<'_, crate::oauth::AppState>, 553 595 did: String, 554 596 ) -> Result<(), ClaimError> { 597 + tracing::info!( 598 + "request_claim_verification command: requesting signature for {}", 599 + did 600 + ); 555 601 // Acquire lock, extract claim state, and release lock before making network call 556 602 let claim_state_copy = { 557 603 let claim_state = state.claim_state.lock().await; ··· 602 648 did: String, 603 649 token: String, 604 650 ) -> Result<VerifiedClaimOp, ClaimError> { 651 + tracing::info!( 652 + "sign_and_verify_claim command: signing and verifying operation for {}", 653 + did 654 + ); 605 655 // Acquire lock, extract required data, and release lock before making network calls 606 656 let (pds_client_ref, oauth_client_ref, claim_did, claim_did_doc) = { 607 657 let claim_state = state.claim_state.lock().await; ··· 609 659 return Err(ClaimError::Unauthorized); 610 660 }; 611 661 662 + // Defense-in-depth: validate caller's DID matches ClaimState 663 + if claim.did != did { 664 + return Err(ClaimError::Unauthorized); 665 + } 666 + 612 667 let Some(ref oauth_client) = claim.pds_oauth_client else { 613 668 return Err(ClaimError::Unauthorized); 614 669 }; ··· 643 698 let mut claim_state = state.claim_state.lock().await; 644 699 if let Some(ref mut claim) = claim_state.as_mut() { 645 700 claim.verified_signed_op = Some(signed_op_json); 701 + } else { 702 + return Err(ClaimError::Unauthorized); 646 703 } 647 704 } 648 705 ··· 652 709 /// Testable core logic for `sign_and_verify_claim`. 653 710 /// 654 711 /// This helper can be called with resolved dependencies without needing Tauri's State. 655 - /// The returned tuple contains (VerifiedClaimOp, signed_op_json_string). 712 + /// The returned tuple contains (VerifiedClaimOp, signed_op_json_value). 656 713 pub(crate) async fn sign_and_verify_claim_impl( 657 714 pds_client: &crate::pds_client::PdsClient, 658 715 pds_oauth_client: &std::sync::Arc<OAuthClient>, ··· 660 717 did_doc: &PlcDidDocument, 661 718 device_key_id: &str, 662 719 token: &str, 663 - ) -> Result<(VerifiedClaimOp, String), ClaimError> { 720 + ) -> Result<(VerifiedClaimOp, serde_json::Value), ClaimError> { 664 721 use crate::pds_client::{ 665 722 get_recommended_did_credentials, sign_plc_operation, SignPlcOperationRequest, 666 723 }; ··· 707 764 } 708 765 })?; 709 766 710 - // Step 4: Serialize operation for verification 711 - let op_json_str = 712 - serde_json::to_string(&response.operation).map_err(|e| ClaimError::NetworkError { 713 - message: format!("failed to serialize operation: {}", e), 714 - })?; 767 + // Step 4: Keep operation as JSON value (no need to serialize/deserialize) 768 + let op_value = response.operation.clone(); 715 769 716 770 // Step 5: Fetch current audit log and get expected prev CID 717 771 let log_json = pds_client ··· 728 782 let expected_prev = audit_log.last().map(|entry| entry.cid.clone()); 729 783 730 784 // Step 6: Verify operation signature 785 + let op_json_str = serde_json::to_string(&op_value).map_err(|e| ClaimError::NetworkError { 786 + message: format!("failed to serialize operation: {}", e), 787 + })?; 788 + 731 789 let authorized_keys: Vec<DidKeyUri> = did_doc 732 790 .rotation_keys 733 791 .iter() ··· 837 895 if !original_services.contains_key(service_id) { 838 896 changed_services.push(ServiceChange { 839 897 id: service_id.clone(), 840 - change_type: "added".to_string(), 898 + change_type: ChangeType::Added, 841 899 old_endpoint: None, 842 900 new_endpoint: Some(service.endpoint.clone()), 843 901 }); ··· 862 920 added_keys, 863 921 removed_keys, 864 922 changed_services, 865 - prev_cid: verified_op.prev.clone().unwrap_or_default(), 923 + prev_cid: verified_op.prev.clone(), 866 924 }; 867 925 868 926 Ok(( 869 927 VerifiedClaimOp { 870 928 diff, 871 - signed_op: op_json_str.clone(), 929 + signed_op: op_value.clone(), 872 930 warnings, 873 931 }, 874 - op_json_str, 932 + op_value, 875 933 )) 876 934 } 877 935 ··· 885 943 /// - `get_or_create_device_key(did)` — ensures device key exists 886 944 /// - Re-fetches the DID document from plc.directory and stores it 887 945 /// - Fetches the PLC audit log and stores it 888 - /// 4. Clears `ClaimState` (set `AppState.claim_state` to `None`) 889 - /// 5. Returns `ClaimResult` with the updated DID document 946 + /// 4. Returns `ClaimResult` with the updated DID document 947 + /// (Caller is responsible for clearing `ClaimState` on success) 890 948 pub(crate) async fn submit_claim_impl( 891 949 pds_client: &crate::pds_client::PdsClient, 892 950 claim_state: &ClaimState, 893 951 ) -> Result<ClaimResult, ClaimError> { 894 952 // Step 1: Read verified_signed_op from ClaimState 895 - let Some(ref verified_signed_op_str) = claim_state.verified_signed_op else { 953 + let Some(ref operation) = claim_state.verified_signed_op else { 896 954 return Err(ClaimError::Unauthorized); 897 955 }; 898 956 899 - // Parse the stored JSON string back to Value 900 - let operation: serde_json::Value = 901 - serde_json::from_str(verified_signed_op_str).map_err(|e| ClaimError::NetworkError { 902 - message: format!("failed to parse verified signed operation: {}", e), 903 - })?; 904 - 905 957 // Step 2: POST the signed operation to plc.directory 906 958 pds_client 907 - .post_plc_operation(&claim_state.did, &operation) 959 + .post_plc_operation(&claim_state.did, operation) 908 960 .await 909 961 .map_err(|e| match e { 910 962 crate::pds_client::PdsClientError::InvalidResponse { message } => { ··· 1003 1055 state: tauri::State<'_, crate::oauth::AppState>, 1004 1056 did: String, 1005 1057 ) -> Result<ClaimResult, ClaimError> { 1058 + tracing::info!("submit_claim command: submitting claim for {}", did); 1006 1059 let pds_client = state.pds_client(); 1007 1060 1008 1061 // Acquire lock, extract claim state, then release lock before network calls ··· 1155 1208 assert_eq!(result, None); 1156 1209 } 1157 1210 1158 - // ── resolve_identity integration tests (AC4.1) ────────────────────────────── 1211 + // ── resolve_identity integration tests ───────────────────────────────────── 1159 1212 1160 1213 /// Test 1: Handle input → correct IdentityInfo verification 1161 1214 /// Verifies that the extract_handle_from_also_known_as and error mapping ··· 1169 1222 let handle = extract_handle_from_also_known_as(&also_known_as) 1170 1223 .expect("Should extract handle from at:// entry"); 1171 1224 1172 - // Assertions matching AC4.1 requirements 1225 + // Verify handle extraction from also_known_as format (at://handle) 1173 1226 assert_eq!(handle, "alice.example.com"); 1174 1227 1175 1228 // Simulate constructing IdentityInfo response ··· 1389 1442 assert_eq!(json["message"], "DNS resolution failed"); 1390 1443 } 1391 1444 1392 - // ── request_claim_verification tests (AC4.2) ────────────────────────────── 1445 + // ── request_claim_verification tests ────────────────────────────────────── 1393 1446 1394 1447 /// Test 1: Success — calls XRPC endpoint with 200 response 1395 - /// Verifies AC4.2: request_claim_verification calls requestPlcOperationSignature on the old PDS 1448 + /// request_claim_verification calls requestPlcOperationSignature on the old PDS 1396 1449 #[tokio::test] 1397 1450 async fn test_request_claim_verification_success() { 1398 1451 use httpmock::MockServer; ··· 1449 1502 } 1450 1503 1451 1504 /// Test 3 (renamed): Unauthorized — no OAuth client 1452 - /// Verifies AC4.2: request_claim_verification returns Unauthorized when pds_oauth_client is None 1505 + /// request_claim_verification returns Unauthorized when pds_oauth_client is None 1453 1506 #[tokio::test] 1454 1507 async fn test_request_claim_verification_unauthorized_no_oauth_client() { 1455 1508 let claim_state = ClaimState { ··· 1474 1527 } 1475 1528 1476 1529 /// Test 4: Network error — PDS returns 500 1477 - /// Verifies AC4.2: request_claim_verification returns NetworkError on PDS failure 1530 + /// request_claim_verification returns NetworkError on PDS failure 1478 1531 #[tokio::test] 1479 1532 async fn test_request_claim_verification_pds_returns_500() { 1480 1533 use httpmock::MockServer; ··· 1568 1621 (rotation.signed_op_json, signing_kp.key_id.0) 1569 1622 } 1570 1623 1571 - /// Test 1: AC4.3 — Success path with device key at rotationKeys[0] 1624 + /// Test 1: Success path with device key at rotationKeys[0] 1572 1625 #[tokio::test] 1573 1626 async fn test_sign_and_verify_claim_success() { 1574 1627 use httpmock::MockServer; ··· 1689 1742 ); 1690 1743 } 1691 1744 1692 - /// Test 2: AC4.4 — Wrong key at rotationKeys[0] 1745 + /// Test 2: Wrong key at rotationKeys[0] 1693 1746 #[tokio::test] 1694 1747 async fn test_sign_and_verify_claim_wrong_key_at_rotation_keys_0() { 1695 1748 use httpmock::MockServer; ··· 1795 1848 ); 1796 1849 } 1797 1850 1798 - /// Test 3: AC4.5 — prev chain mismatch 1851 + /// Test 3: prev chain mismatch 1799 1852 #[tokio::test] 1800 1853 async fn test_sign_and_verify_claim_prev_mismatch() { 1801 1854 use httpmock::MockServer; ··· 1901 1954 ); 1902 1955 } 1903 1956 1904 - /// Test 4: AC4.6 — unexpected key removal 1957 + /// Test 4: unexpected key removal 1905 1958 #[tokio::test] 1906 1959 async fn test_sign_and_verify_claim_unexpected_key_removal() { 1907 1960 use httpmock::MockServer; ··· 2006 2059 ); 2007 2060 } 2008 2061 2009 - /// Test 5: AC4.6 — unexpected service change 2062 + /// Test 5: unexpected service change 2010 2063 #[tokio::test] 2011 2064 async fn test_sign_and_verify_claim_unexpected_service_change() { 2012 2065 use httpmock::MockServer; ··· 2118 2171 ); 2119 2172 } 2120 2173 2121 - /// Test 6: AC4.7 — warnings for benign additions 2174 + /// Test 6: warnings for benign additions 2122 2175 #[tokio::test] 2123 2176 async fn test_sign_and_verify_claim_warnings_for_added_service() { 2124 2177 use httpmock::MockServer; ··· 2246 2299 ); 2247 2300 } 2248 2301 2249 - /// Test 7: AC4.10 — Invalid token error from PDS 2302 + /// Test 7: Invalid token error from PDS 2250 2303 #[tokio::test] 2251 2304 async fn test_sign_and_verify_claim_invalid_token() { 2252 2305 use httpmock::MockServer; ··· 2321 2374 ); 2322 2375 } 2323 2376 2324 - // ── submit_claim tests (AC4.8, AC4.9) ────────────────────────────────── 2377 + // ── submit_claim tests ──────────────────────────────────────────────────── 2325 2378 2326 - /// Test AC4.8 — Success: submit_claim POSTs signed operation and persists identity 2379 + /// Test Success: submit_claim POSTs signed operation and persists identity 2327 2380 #[tokio::test] 2328 2381 async fn test_submit_claim_success() { 2329 2382 use httpmock::MockServer; ··· 2413 2466 assert_eq!(claim_result.updated_did_doc["did"], "did:plc:test"); 2414 2467 } 2415 2468 2416 - /// Test AC4.9 — Failure: submit_claim returns PlcDirectoryError when POST fails 2469 + /// Test Failure: submit_claim returns PlcDirectoryError when POST fails 2417 2470 #[tokio::test] 2418 2471 async fn test_submit_claim_plc_directory_error() { 2419 2472 use httpmock::MockServer;
+20 -5
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 371 371 372 372 let status = response.status(); 373 373 if !status.is_success() { 374 - let error_body = response.text().await.unwrap_or_default(); 374 + let error_body = response 375 + .text() 376 + .await 377 + .unwrap_or_else(|_| "(response body unreadable)".to_string()); 375 378 return Err(PdsClientError::OauthFailed { 376 379 message: format!("PAR returned {}: {}", status, error_body), 377 380 }); ··· 494 497 if resp.status().is_success() { 495 498 Ok(()) 496 499 } else { 497 - let body = resp.text().await.unwrap_or_default(); 500 + let body = resp 501 + .text() 502 + .await 503 + .unwrap_or_else(|_| "(response body unreadable)".to_string()); 498 504 Err(PdsClientError::InvalidResponse { 499 505 message: format!("plc.directory rejected operation: {}", body), 500 506 }) ··· 617 623 if status.is_success() { 618 624 Ok(()) 619 625 } else { 620 - let body = resp.text().await.unwrap_or_default(); 626 + let body = resp 627 + .text() 628 + .await 629 + .unwrap_or_else(|_| "(response body unreadable)".to_string()); 621 630 Err(PdsClientError::NetworkError { 622 631 message: format!( 623 632 "request_plc_operation_signature returned {}: {}", ··· 641 650 642 651 let status = resp.status(); 643 652 if !status.is_success() { 644 - let body = resp.text().await.unwrap_or_default(); 653 + let body = resp 654 + .text() 655 + .await 656 + .unwrap_or_else(|_| "(response body unreadable)".to_string()); 645 657 return Err(PdsClientError::NetworkError { 646 658 message: format!("sign_plc_operation returned {}: {}", status, body), 647 659 }); ··· 667 679 668 680 let status = resp.status(); 669 681 if !status.is_success() { 670 - let body = resp.text().await.unwrap_or_default(); 682 + let body = resp 683 + .text() 684 + .await 685 + .unwrap_or_else(|_| "(response body unreadable)".to_string()); 671 686 return Err(PdsClientError::NetworkError { 672 687 message: format!( 673 688 "get_recommended_did_credentials returned {}: {}",
+101 -3
apps/identity-wallet/src/lib/ipc.ts
··· 307 307 308 308 // ── Claim flow types ────────────────────────────────────────────────────── 309 309 310 + /** 311 + * Identity information resolved from a handle or DID. 312 + * 313 + * Returned by `resolveIdentity` command. Contains the DID, handle, PDS endpoint, 314 + * current rotation keys from the DID document, and whether the device key is 315 + * the primary rotation key (rotationKeys[0]). 316 + */ 310 317 export interface IdentityInfo { 318 + /** The DID (e.g., "did:plc:abc123...") */ 311 319 did: string; 320 + /** The handle (e.g., "alice.test") */ 312 321 handle: string; 322 + /** The PDS endpoint URL (e.g., "https://pds.example.com") */ 313 323 pdsUrl: string; 324 + /** Current rotation keys from the DID document */ 314 325 currentRotationKeys: string[]; 326 + /** Whether the device key is a rotation key (true if device key == rotationKeys[0]) */ 315 327 deviceKeyIsRoot: boolean; 316 328 } 317 329 330 + /** 331 + * Verified claim operation ready for submission. 332 + * 333 + * Returned by `signAndVerifyClaim` command. Contains the diff between the 334 + * current DID document and the proposed operation, the signed operation 335 + * itself (as a JSON object), and any warnings from verification. 336 + */ 318 337 export interface VerifiedClaimOp { 338 + /** Diff of keys and services between current DID doc and proposed operation */ 319 339 diff: OpDiff; 320 - signedOp: string; 340 + /** Signed operation (ready for PLC submission) as a JSON object */ 341 + signedOp: Record<string, unknown>; 342 + /** Warnings from verification (e.g., "This operation will break X") */ 321 343 warnings: string[]; 322 344 } 323 345 346 + /** 347 + * Diff of changes between current DID document and proposed operation. 348 + * 349 + * Shows which keys and services are being added, removed, or modified 350 + * in the claim operation, along with the previous CID for verification. 351 + */ 324 352 export interface OpDiff { 353 + /** Keys being added in this operation */ 325 354 addedKeys: string[]; 355 + /** Keys being removed in this operation */ 326 356 removedKeys: string[]; 357 + /** Service endpoint changes (added/removed/modified) */ 327 358 changedServices: ServiceChange[]; 328 - prevCid: string; 359 + /** Previous CID (content identifier) of the DID document, or null if no prior operation */ 360 + prevCid: string | null; 329 361 } 330 362 363 + /** 364 + * Type of change to a service endpoint. 365 + */ 366 + export type ChangeType = 'added' | 'removed' | 'modified'; 367 + 368 + /** 369 + * Change to a service endpoint in the DID document. 370 + * 371 + * Represents a single service change with the service ID, type of change, 372 + * and the old/new endpoint URLs (where applicable). 373 + */ 331 374 export interface ServiceChange { 375 + /** Service ID (e.g., "atproto_pds") */ 332 376 id: string; 333 - changeType: string; 377 + /** Type of change: added, removed, or modified */ 378 + changeType: ChangeType; 379 + /** Old endpoint URL (null if added) */ 334 380 oldEndpoint: string | null; 381 + /** New endpoint URL (null if removed) */ 335 382 newEndpoint: string | null; 336 383 } 337 384 385 + /** 386 + * Result of a successful claim submission. 387 + * 388 + * Returned by `submitClaim` command. Contains the updated DID document 389 + * after the claim was applied. 390 + */ 338 391 export interface ClaimResult { 392 + /** Updated DID document after claim was applied */ 339 393 updatedDidDoc: Record<string, unknown>; 340 394 } 341 395 342 396 // ── Claim flow error types ──────────────────────────────────────────────── 343 397 398 + /** 399 + * Error returned by `resolveIdentity` command. 400 + * 401 + * Serialized as `{ code: "HANDLE_NOT_FOUND" }` etc. by the Rust backend. 402 + */ 344 403 export type ResolveError = 345 404 | { code: 'HANDLE_NOT_FOUND' } 346 405 | { code: 'DID_NOT_FOUND' } 347 406 | { code: 'PDS_UNREACHABLE' } 348 407 | { code: 'NETWORK_ERROR'; message: string }; 349 408 409 + /** 410 + * Error returned by claim flow commands. 411 + * 412 + * Serialized as `{ code: "INVALID_TOKEN" }` etc. by the Rust backend. 413 + * Variants with a `message` field are serialized with that field as well. 414 + */ 350 415 export type ClaimError = 351 416 | { code: 'INVALID_TOKEN' } 352 417 | { code: 'VERIFICATION_FAILED'; message: string } ··· 356 421 357 422 // ── Claim flow IPC wrappers ──────────────────────────────────────────────── 358 423 424 + /** 425 + * Resolve a handle or DID to identity information. 426 + * 427 + * This is the first command in the claim flow. Returns identity info including 428 + * the DID, handle, PDS endpoint, and current rotation keys from the DID document. 429 + * Stores claim state internally for use by subsequent claim commands. 430 + */ 359 431 export const resolveIdentity = (handleOrDid: string): Promise<IdentityInfo> => 360 432 invoke('resolve_identity', { handleOrDid }); 361 433 434 + /** 435 + * Authenticate with the old PDS via OAuth 2.0 PKCE + DPoP. 436 + * 437 + * Opens Safari for user authentication and handles the OAuth callback via deep-link. 438 + * On success, stores the OAuth client in claim state for use by subsequent commands. 439 + * Emits `pds_auth_ready` event when complete. 440 + */ 362 441 export const startPdsAuth = (pdsUrl: string): Promise<void> => 363 442 invoke('start_pds_auth', { pdsUrl }); 364 443 444 + /** 445 + * Request email verification for the PLC operation. 446 + * 447 + * Calls `requestPlcOperationSignature` on the old PDS to trigger email verification. 448 + * Must be called after `startPdsAuth` succeeds. 449 + */ 365 450 export const requestClaimVerification = (did: string): Promise<void> => 366 451 invoke('request_claim_verification', { did }); 367 452 453 + /** 454 + * Sign and verify a PLC operation. 455 + * 456 + * Coordinates three systems: old PDS (for signing), plc.directory (for audit log), 457 + * and local verification (4-point checks). Returns a verified operation ready for 458 + * submission. 459 + */ 368 460 export const signAndVerifyClaim = (did: string, token: string): Promise<VerifiedClaimOp> => 369 461 invoke('sign_and_verify_claim', { did, token }); 370 462 463 + /** 464 + * Submit a verified signed claim operation to plc.directory. 465 + * 466 + * This is the final step in the claim flow. POSTs the signed operation to 467 + * plc.directory and persists the claimed identity to the local identity store. 468 + */ 371 469 export const submitClaim = (did: string): Promise<ClaimResult> => 372 470 invoke('submit_claim', { did });