An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

fix(MM-144): address second round of PR review feedback

- Complete compensation path: session-token failure now also deletes
device-token to avoid orphaned credentials
- Extract map_409_subcode() helper; test calls the real function instead
of duplicating its logic
- HandleScreen: tighten handle regex to RFC 1035 DNS label (no dots or
underscores — these create multi-label handles or violate DNS spec)
- CLAUDE.md: document delete_item in both keychain listings

authored by malpercio.dev and committed by

Tangled 3b7b90ce a3c19f15

+28 -74
+2 -2
apps/identity-wallet/CLAUDE.md
··· 29 29 30 30 **Exposes:** 31 31 - `src/lib.rs::create_account(claim_code: String, email: String, handle: String) -> Result<CreateAccountResult, CreateAccountError>` — Tauri IPC command: generates P-256 keypair, stores private key in Keychain, POSTs to relay `/v1/accounts/mobile`, stores tokens in Keychain on success 32 - - `src/keychain.rs` — iOS Keychain abstraction (`store_item`, `get_item`) under service `"ezpds-identity-wallet"` 32 + - `src/keychain.rs` — iOS Keychain abstraction (`store_item`, `get_item`, `delete_item`) under service `"ezpds-identity-wallet"` 33 33 - `src/http.rs` — `RelayClient` with compile-time base URL (localhost:8080 debug, relay.ezpds.com release) 34 34 35 35 **Guarantees:** ··· 157 157 - `src-tauri/tauri.conf.json` -- Tauri config: bundle ID, devUrl, frontendDist, window settings 158 158 - `src-tauri/src/lib.rs` -- Tauri IPC commands (`create_account`) and `run()` (mobile entry point) 159 159 - `src-tauri/src/main.rs` -- Desktop entry point (calls `lib::run()`) 160 - - `src-tauri/src/keychain.rs` -- iOS Keychain abstraction (store_item, get_item) 160 + - `src-tauri/src/keychain.rs` -- iOS Keychain abstraction (store_item, get_item, delete_item) 161 161 - `src-tauri/src/http.rs` -- RelayClient with compile-time base URL 162 162 - `src-tauri/.cargo/config.toml` -- Cargo toolchain overrides for iOS cross-compilation (CC, AR, linker per target) 163 163 - `src/lib/ipc.ts` -- Typed TypeScript wrappers for all Tauri IPC commands (createAccount)
+22 -69
apps/identity-wallet/src-tauri/src/lib.rs
··· 79 79 80 80 static RELAY_CLIENT: LazyLock<http::RelayClient> = LazyLock::new(http::RelayClient::new); 81 81 82 + // ── Helpers ───────────────────────────────────────────────────────────────── 83 + 84 + /// Map a relay 409 error subcode string to a typed `CreateAccountError` variant. 85 + fn map_409_subcode(code: &str) -> CreateAccountError { 86 + match code { 87 + "CLAIM_CODE_REDEEMED" => CreateAccountError::RedeemedCode, 88 + "ACCOUNT_EXISTS" => CreateAccountError::EmailTaken, 89 + "HANDLE_TAKEN" => CreateAccountError::HandleTaken, 90 + other => CreateAccountError::Unknown { 91 + message: format!("409: {other}"), 92 + }, 93 + } 94 + } 95 + 82 96 // ── IPC command ───────────────────────────────────────────────────────────── 83 97 84 98 #[tauri::command] ··· 132 146 })?; 133 147 134 148 keychain::store_item("session-token", body.session_token.as_bytes()).map_err(|_| { 135 - // Best-effort cleanup: ignore deletion errors. 149 + // Best-effort cleanup: also remove the already-written device-token so the 150 + // Keychain doesn't hold a credential for an account the device can't access. 136 151 let _ = keychain::delete_item("device-private-key"); 152 + let _ = keychain::delete_item("device-token"); 137 153 CreateAccountError::KeychainError 138 154 })?; 139 155 ··· 151 167 resp.json().await.map_err(|e| CreateAccountError::Unknown { 152 168 message: e.to_string(), 153 169 })?; 154 - match envelope.error.code.as_str() { 155 - "CLAIM_CODE_REDEEMED" => Err(CreateAccountError::RedeemedCode), 156 - "ACCOUNT_EXISTS" => Err(CreateAccountError::EmailTaken), 157 - "HANDLE_TAKEN" => Err(CreateAccountError::HandleTaken), 158 - other => Err(CreateAccountError::Unknown { 159 - message: format!("409: {other}"), 160 - }), 161 - } 170 + Err(map_409_subcode(&envelope.error.code)) 162 171 } 163 172 _ => Err(CreateAccountError::NetworkError { 164 173 message: format!("HTTP {}", status.as_u16()), ··· 272 281 // -- 409 subcode dispatch table -- 273 282 #[test] 274 283 fn error_409_dispatch_maps_subcodes_correctly() { 275 - // Test CLAIM_CODE_REDEEMED subcode 276 - let envelope = RelayErrorEnvelope { 277 - error: RelayErrorBody { 278 - code: "CLAIM_CODE_REDEEMED".to_string(), 279 - }, 280 - }; 281 - let err = match envelope.error.code.as_str() { 282 - "CLAIM_CODE_REDEEMED" => CreateAccountError::RedeemedCode, 283 - "ACCOUNT_EXISTS" => CreateAccountError::EmailTaken, 284 - "HANDLE_TAKEN" => CreateAccountError::HandleTaken, 285 - other => CreateAccountError::Unknown { 286 - message: format!("409: {other}"), 287 - }, 288 - }; 289 - let json = serde_json::to_value(&err).unwrap(); 284 + let json = serde_json::to_value(map_409_subcode("CLAIM_CODE_REDEEMED")).unwrap(); 290 285 assert_eq!(json["code"], "REDEEMED_CODE"); 291 286 292 - // Test ACCOUNT_EXISTS subcode 293 - let envelope = RelayErrorEnvelope { 294 - error: RelayErrorBody { 295 - code: "ACCOUNT_EXISTS".to_string(), 296 - }, 297 - }; 298 - let err = match envelope.error.code.as_str() { 299 - "CLAIM_CODE_REDEEMED" => CreateAccountError::RedeemedCode, 300 - "ACCOUNT_EXISTS" => CreateAccountError::EmailTaken, 301 - "HANDLE_TAKEN" => CreateAccountError::HandleTaken, 302 - other => CreateAccountError::Unknown { 303 - message: format!("409: {other}"), 304 - }, 305 - }; 306 - let json = serde_json::to_value(&err).unwrap(); 287 + let json = serde_json::to_value(map_409_subcode("ACCOUNT_EXISTS")).unwrap(); 307 288 assert_eq!(json["code"], "EMAIL_TAKEN"); 308 289 309 - // Test HANDLE_TAKEN subcode 310 - let envelope = RelayErrorEnvelope { 311 - error: RelayErrorBody { 312 - code: "HANDLE_TAKEN".to_string(), 313 - }, 314 - }; 315 - let err = match envelope.error.code.as_str() { 316 - "CLAIM_CODE_REDEEMED" => CreateAccountError::RedeemedCode, 317 - "ACCOUNT_EXISTS" => CreateAccountError::EmailTaken, 318 - "HANDLE_TAKEN" => CreateAccountError::HandleTaken, 319 - other => CreateAccountError::Unknown { 320 - message: format!("409: {other}"), 321 - }, 322 - }; 323 - let json = serde_json::to_value(&err).unwrap(); 290 + let json = serde_json::to_value(map_409_subcode("HANDLE_TAKEN")).unwrap(); 324 291 assert_eq!(json["code"], "HANDLE_TAKEN"); 325 292 326 - // Test unknown subcode (falls through to Unknown) 327 - let envelope = RelayErrorEnvelope { 328 - error: RelayErrorBody { 329 - code: "UNKNOWN_SUBCODE".to_string(), 330 - }, 331 - }; 332 - let err = match envelope.error.code.as_str() { 333 - "CLAIM_CODE_REDEEMED" => CreateAccountError::RedeemedCode, 334 - "ACCOUNT_EXISTS" => CreateAccountError::EmailTaken, 335 - "HANDLE_TAKEN" => CreateAccountError::HandleTaken, 336 - other => CreateAccountError::Unknown { 337 - message: format!("409: {other}"), 338 - }, 339 - }; 340 - let json = serde_json::to_value(&err).unwrap(); 293 + let json = serde_json::to_value(map_409_subcode("UNKNOWN_SUBCODE")).unwrap(); 341 294 assert_eq!(json["code"], "UNKNOWN"); 342 295 assert!(json["message"].as_str().unwrap().contains("409:")); 343 296 }
+4 -3
apps/identity-wallet/src/lib/components/onboarding/HandleScreen.svelte
··· 9 9 error?: string; 10 10 } = $props(); 11 11 12 - // ATProto handle validation: alphanumeric start/end, dots/hyphens/underscores allowed in middle. 13 - // Minimum 1 character, maximum typically 63 (per DNS labels). 14 - const handleRegex = /^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; 12 + // ATProto handle segment: RFC 1035 DNS label — alphanumeric start/end, hyphens in middle only. 13 + // Dots and underscores are excluded: dots would create multi-label handles (alice.bob instead of 14 + // alice.ezpds.com); underscores are not valid in DNS labels per RFC 1035. 15 + const handleRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; 15 16 let isValid = $derived(handleRegex.test(value.trim())); 16 17 </script> 17 18