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 resolve_identity command (AC4.1)

- Implement resolve_identity Tauri command that resolves handle/DID to IdentityInfo
- Determines if input is DID (starts with 'did:') or handle
- For handles: uses PdsClient::resolve_handle() to get DID
- Calls PdsClient::discover_pds() to fetch DID doc from plc.directory
- Extracts handle from also_known_as entries (format: at://handle)
- Checks IdentityStore for device key registration status
- Stores resolved state in AppState.claim_state
- Maps PdsClientError variants to ResolveError
- Add helper function to extract handle from also_known_as
- Add tests for handle extraction and error mapping

authored by

Malpercio and committed by
Tangled
2c42664a 336a38eb

+390 -5
+390 -5
apps/identity-wallet/src-tauri/src/claim.rs
··· 1 - // pattern: Functional Core (types and errors) 1 + // pattern: Mixed (Functional Core types + Imperative Shell command) 2 2 // 3 - // Types: IdentityInfo, VerifiedClaimOp, OpDiff, ServiceChange, ClaimResult, 4 - // ClaimState, ResolveError, ClaimError 5 - // These are all data structures with no side effects. 3 + // Functional Core: IdentityInfo, VerifiedClaimOp, OpDiff, ServiceChange, ClaimResult, 4 + // ClaimState, ResolveError, ClaimError (types and errors) 5 + // Imperative Shell: resolve_identity (command: resolves handle/DID, fetches DID doc from 6 + // plc.directory, checks IdentityStore, stores state, returns IdentityInfo) 6 7 7 8 use serde::Serialize; 8 9 10 + use crate::identity_store::IdentityStore; 9 11 use crate::oauth_client::OAuthClient; 10 - use crate::pds_client::PlcDidDocument; 12 + use crate::pds_client::{PlcDidDocument, PdsClientError}; 11 13 12 14 // ── Output types ─────────────────────────────────────────────────────────── 13 15 ··· 136 138 /// Network error during claim flow (timeout, connection refused, etc.) 137 139 NetworkError { message: String }, 138 140 } 141 + 142 + // ── resolve_identity Tauri command ────────────────────────────────────────── 143 + 144 + /// Resolve a handle or DID to identity information. 145 + /// 146 + /// This is the first command in the claim flow. It: 147 + /// 1. Determines if input is a DID (starts with "did:") or a handle 148 + /// 2. If handle: resolves to DID via `PdsClient::resolve_handle()` 149 + /// 3. Fetches DID doc from plc.directory via `PdsClient::discover_pds()` 150 + /// 4. Extracts handle from `also_known_as` (format: `at://handle`) 151 + /// 5. Checks IdentityStore to determine if DID is registered 152 + /// 6. If registered: gets or creates device key and compares to rotation_keys[0] 153 + /// 7. Stores resolved state in AppState.claim_state 154 + /// 8. Returns IdentityInfo with all discovery data 155 + #[tauri::command] 156 + pub async fn resolve_identity( 157 + state: tauri::State<'_, crate::oauth::AppState>, 158 + handle_or_did: String, 159 + ) -> Result<IdentityInfo, ResolveError> { 160 + let pds_client = state.pds_client(); 161 + 162 + // Determine if input is a DID or handle 163 + let is_did = handle_or_did.starts_with("did:"); 164 + let (did, mut handle_for_fallback) = if is_did { 165 + (handle_or_did.clone(), None) 166 + } else { 167 + ( 168 + pds_client 169 + .resolve_handle(&handle_or_did) 170 + .await 171 + .map_err(map_pds_error_to_resolve)?, 172 + Some(handle_or_did.clone()), 173 + ) 174 + }; 175 + 176 + // Fetch DID document and PDS endpoint from plc.directory 177 + let (pds_url, did_doc) = pds_client 178 + .discover_pds(&did) 179 + .await 180 + .map_err(map_pds_error_to_resolve)?; 181 + 182 + // Extract handle from also_known_as (format: at://handle) 183 + let handle = extract_handle_from_also_known_as(&did_doc.also_known_as) 184 + .or_else(|| handle_for_fallback.take()) 185 + .unwrap_or_else(|| { 186 + if is_did { 187 + "unknown".to_string() 188 + } else { 189 + // We already resolved this handle, use it 190 + handle_or_did.clone() 191 + } 192 + }); 193 + 194 + // Check if DID is registered and get device key status 195 + let device_key_is_root = { 196 + let identity_store = IdentityStore; 197 + match identity_store.list_identities() { 198 + Ok(identities) => { 199 + if identities.contains(&did) { 200 + // DID is registered, get device key and compare to rotation_keys[0] 201 + match identity_store.get_or_create_device_key(&did) { 202 + Ok(device_key) => { 203 + // Compare multibase-encoded device key with rotation_keys[0] 204 + did_doc 205 + .rotation_keys 206 + .first() 207 + .map(|first_key| device_key.multibase == *first_key) 208 + .unwrap_or(false) 209 + } 210 + Err(_) => false, // Key generation failed, assume not root 211 + } 212 + } else { 213 + false // DID not registered 214 + } 215 + } 216 + Err(_) => false, // Store lookup failed, assume not root 217 + } 218 + }; 219 + 220 + // Store claim state in AppState 221 + let claim_state = ClaimState { 222 + did: did.clone(), 223 + pds_url: pds_url.clone(), 224 + did_doc: did_doc.clone(), 225 + pds_oauth_client: None, 226 + verified_signed_op: None, 227 + }; 228 + 229 + let mut state_lock = state.claim_state.lock().await; 230 + *state_lock = Some(claim_state); 231 + drop(state_lock); 232 + 233 + Ok(IdentityInfo { 234 + did, 235 + handle, 236 + pds_url, 237 + current_rotation_keys: did_doc.rotation_keys, 238 + device_key_is_root, 239 + }) 240 + } 241 + 242 + /// Map PdsClientError to ResolveError. 243 + fn map_pds_error_to_resolve(err: PdsClientError) -> ResolveError { 244 + match err { 245 + PdsClientError::HandleNotFound => ResolveError::HandleNotFound, 246 + PdsClientError::DidNotFound => ResolveError::DidNotFound, 247 + PdsClientError::PdsUnreachable { .. } => ResolveError::PdsUnreachable, 248 + PdsClientError::NetworkError { message } => ResolveError::NetworkError { message }, 249 + PdsClientError::InvalidResponse { message } => ResolveError::NetworkError { message }, 250 + PdsClientError::OauthFailed { message } => ResolveError::NetworkError { message }, 251 + } 252 + } 253 + 254 + /// Extract handle from also_known_as entries. 255 + /// 256 + /// Searches for entries of the form "at://handle" and returns the first match. 257 + /// Returns None if no such entries are found. 258 + fn extract_handle_from_also_known_as(also_known_as: &[String]) -> Option<String> { 259 + for entry in also_known_as { 260 + if let Some(handle) = entry.strip_prefix("at://") { 261 + return Some(handle.to_string()); 262 + } 263 + } 264 + None 265 + } 266 + 267 + #[cfg(test)] 268 + mod tests { 269 + use super::*; 270 + 271 + // ── resolve_identity tests ────────────────────────────────────────────────── 272 + 273 + #[test] 274 + fn test_resolve_identity_maps_pds_error_handle_not_found() { 275 + let err = PdsClientError::HandleNotFound; 276 + let result = map_pds_error_to_resolve(err); 277 + match result { 278 + ResolveError::HandleNotFound => {} 279 + _ => panic!("Expected HandleNotFound"), 280 + } 281 + } 282 + 283 + #[test] 284 + fn test_resolve_identity_maps_pds_error_did_not_found() { 285 + let err = PdsClientError::DidNotFound; 286 + let result = map_pds_error_to_resolve(err); 287 + match result { 288 + ResolveError::DidNotFound => {} 289 + _ => panic!("Expected DidNotFound"), 290 + } 291 + } 292 + 293 + #[test] 294 + fn test_resolve_identity_maps_pds_error_pds_unreachable() { 295 + let err = PdsClientError::PdsUnreachable { 296 + reason: "Connection refused".to_string(), 297 + }; 298 + let result = map_pds_error_to_resolve(err); 299 + match result { 300 + ResolveError::PdsUnreachable => {} 301 + _ => panic!("Expected PdsUnreachable"), 302 + } 303 + } 304 + 305 + #[test] 306 + fn test_resolve_identity_maps_pds_error_network_error() { 307 + let err = PdsClientError::NetworkError { 308 + message: "Timeout".to_string(), 309 + }; 310 + let result = map_pds_error_to_resolve(err); 311 + match result { 312 + ResolveError::NetworkError { message } => { 313 + assert_eq!(message, "Timeout"); 314 + } 315 + _ => panic!("Expected NetworkError"), 316 + } 317 + } 318 + 319 + #[test] 320 + fn test_resolve_identity_maps_pds_error_invalid_response() { 321 + let err = PdsClientError::InvalidResponse { 322 + message: "Invalid JSON".to_string(), 323 + }; 324 + let result = map_pds_error_to_resolve(err); 325 + match result { 326 + ResolveError::NetworkError { message } => { 327 + assert_eq!(message, "Invalid JSON"); 328 + } 329 + _ => panic!("Expected NetworkError"), 330 + } 331 + } 332 + 333 + #[test] 334 + fn test_extract_handle_from_also_known_as_valid() { 335 + let entries = vec!["at://alice.test".to_string()]; 336 + let result = extract_handle_from_also_known_as(&entries); 337 + assert_eq!(result, Some("alice.test".to_string())); 338 + } 339 + 340 + #[test] 341 + fn test_extract_handle_from_also_known_as_multiple_entries() { 342 + let entries = vec![ 343 + "https://example.com/user/alice".to_string(), 344 + "at://alice.test".to_string(), 345 + ]; 346 + let result = extract_handle_from_also_known_as(&entries); 347 + assert_eq!(result, Some("alice.test".to_string())); 348 + } 349 + 350 + #[test] 351 + fn test_extract_handle_from_also_known_as_empty() { 352 + let entries: Vec<String> = vec![]; 353 + let result = extract_handle_from_also_known_as(&entries); 354 + assert_eq!(result, None); 355 + } 356 + 357 + #[test] 358 + fn test_extract_handle_from_also_known_as_no_at_prefix() { 359 + let entries = vec!["https://example.com/user/alice".to_string()]; 360 + let result = extract_handle_from_also_known_as(&entries); 361 + assert_eq!(result, None); 362 + } 363 + 364 + // ── Serialization tests for claim types ────────────────────────────────── 365 + 366 + #[test] 367 + fn test_identity_info_serializes_camel_case() { 368 + let info = IdentityInfo { 369 + did: "did:plc:test".to_string(), 370 + handle: "alice.test".to_string(), 371 + pds_url: "https://pds.example.com".to_string(), 372 + current_rotation_keys: vec!["did:key:zQ3rot1".to_string()], 373 + device_key_is_root: true, 374 + }; 375 + 376 + let json = serde_json::to_value(&info).unwrap(); 377 + assert_eq!(json["did"], "did:plc:test"); 378 + assert_eq!(json["handle"], "alice.test"); 379 + assert_eq!(json["pdsUrl"], "https://pds.example.com"); 380 + assert_eq!(json["currentRotationKeys"][0], "did:key:zQ3rot1"); 381 + assert_eq!(json["deviceKeyIsRoot"], true); 382 + } 383 + 384 + #[test] 385 + fn test_verified_claim_op_serializes_camel_case() { 386 + let op = VerifiedClaimOp { 387 + diff: OpDiff { 388 + added_keys: vec!["did:key:zQ3new".to_string()], 389 + removed_keys: vec![], 390 + changed_services: vec![], 391 + prev_cid: "bagXXX".to_string(), 392 + }, 393 + signed_op: "eyJzaWciOiAi...".to_string(), 394 + warnings: vec!["This will change ownership".to_string()], 395 + }; 396 + 397 + let json = serde_json::to_value(&op).unwrap(); 398 + assert_eq!(json["signedOp"], "eyJzaWciOiAi..."); 399 + assert!(json["diff"].is_object()); 400 + assert_eq!(json["warnings"][0], "This will change ownership"); 401 + } 402 + 403 + #[test] 404 + fn test_op_diff_serializes_camel_case() { 405 + let diff = OpDiff { 406 + added_keys: vec!["did:key:zQ3new".to_string()], 407 + removed_keys: vec!["did:key:zQ3old".to_string()], 408 + changed_services: vec![], 409 + prev_cid: "bagXXX".to_string(), 410 + }; 411 + 412 + let json = serde_json::to_value(&diff).unwrap(); 413 + assert_eq!(json["addedKeys"][0], "did:key:zQ3new"); 414 + assert_eq!(json["removedKeys"][0], "did:key:zQ3old"); 415 + assert_eq!(json["prevCid"], "bagXXX"); 416 + assert!(json["changedServices"].is_array()); 417 + } 418 + 419 + #[test] 420 + fn test_service_change_serializes_camel_case() { 421 + let change = ServiceChange { 422 + id: "atproto_pds".to_string(), 423 + change_type: "modified".to_string(), 424 + old_endpoint: Some("https://pds-old.example.com".to_string()), 425 + new_endpoint: Some("https://pds-new.example.com".to_string()), 426 + }; 427 + 428 + let json = serde_json::to_value(&change).unwrap(); 429 + assert_eq!(json["id"], "atproto_pds"); 430 + assert_eq!(json["changeType"], "modified"); 431 + assert_eq!(json["oldEndpoint"], "https://pds-old.example.com"); 432 + assert_eq!(json["newEndpoint"], "https://pds-new.example.com"); 433 + } 434 + 435 + #[test] 436 + fn test_claim_result_serializes_camel_case() { 437 + let result = ClaimResult { 438 + updated_did_doc: serde_json::json!({ 439 + "did": "did:plc:test", 440 + "rotationKeys": ["did:key:zQ3new"] 441 + }), 442 + }; 443 + 444 + let json = serde_json::to_value(&result).unwrap(); 445 + assert!(json["updatedDidDoc"].is_object()); 446 + assert_eq!(json["updatedDidDoc"]["did"], "did:plc:test"); 447 + } 448 + 449 + #[test] 450 + fn test_resolve_error_handle_not_found_serializes_correctly() { 451 + let err = ResolveError::HandleNotFound; 452 + let json = serde_json::to_value(&err).unwrap(); 453 + assert_eq!(json["code"], "HANDLE_NOT_FOUND"); 454 + } 455 + 456 + #[test] 457 + fn test_resolve_error_did_not_found_serializes_correctly() { 458 + let err = ResolveError::DidNotFound; 459 + let json = serde_json::to_value(&err).unwrap(); 460 + assert_eq!(json["code"], "DID_NOT_FOUND"); 461 + } 462 + 463 + #[test] 464 + fn test_resolve_error_pds_unreachable_serializes_correctly() { 465 + let err = ResolveError::PdsUnreachable; 466 + let json = serde_json::to_value(&err).unwrap(); 467 + assert_eq!(json["code"], "PDS_UNREACHABLE"); 468 + } 469 + 470 + #[test] 471 + fn test_resolve_error_network_error_serializes_correctly() { 472 + let err = ResolveError::NetworkError { 473 + message: "Connection timeout".to_string(), 474 + }; 475 + let json = serde_json::to_value(&err).unwrap(); 476 + assert_eq!(json["code"], "NETWORK_ERROR"); 477 + assert_eq!(json["message"], "Connection timeout"); 478 + } 479 + 480 + #[test] 481 + fn test_claim_error_invalid_token_serializes_correctly() { 482 + let err = ClaimError::InvalidToken; 483 + let json = serde_json::to_value(&err).unwrap(); 484 + assert_eq!(json["code"], "INVALID_TOKEN"); 485 + } 486 + 487 + #[test] 488 + fn test_claim_error_verification_failed_serializes_correctly() { 489 + let err = ClaimError::VerificationFailed { 490 + message: "Signature mismatch".to_string(), 491 + }; 492 + let json = serde_json::to_value(&err).unwrap(); 493 + assert_eq!(json["code"], "VERIFICATION_FAILED"); 494 + assert_eq!(json["message"], "Signature mismatch"); 495 + } 496 + 497 + #[test] 498 + fn test_claim_error_plc_directory_error_serializes_correctly() { 499 + let err = ClaimError::PlcDirectoryError { 500 + message: "Invalid operation".to_string(), 501 + }; 502 + let json = serde_json::to_value(&err).unwrap(); 503 + assert_eq!(json["code"], "PLC_DIRECTORY_ERROR"); 504 + assert_eq!(json["message"], "Invalid operation"); 505 + } 506 + 507 + #[test] 508 + fn test_claim_error_unauthorized_serializes_correctly() { 509 + let err = ClaimError::Unauthorized; 510 + let json = serde_json::to_value(&err).unwrap(); 511 + assert_eq!(json["code"], "UNAUTHORIZED"); 512 + } 513 + 514 + #[test] 515 + fn test_claim_error_network_error_serializes_correctly() { 516 + let err = ClaimError::NetworkError { 517 + message: "DNS resolution failed".to_string(), 518 + }; 519 + let json = serde_json::to_value(&err).unwrap(); 520 + assert_eq!(json["code"], "NETWORK_ERROR"); 521 + assert_eq!(json["message"], "DNS resolution failed"); 522 + } 523 + }