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: add handle registration screen and IPC for MM-148

Adds the final onboarding step: handle registration and HTTP resolution
polling. After the Shamir backup step, the app calls POST /v1/handles to
register the handle, then polls resolveHandle until the DID is resolvable
via HTTP (2-minute timeout; proceeds anyway if it times out).

- Rust: register_handle IPC command (fetches domain from describeServer,
reads DID + session token from Keychain, POSTs to /v1/handles)
- Rust: check_handle_resolution IPC command (polls resolveHandle endpoint,
never rejects — safe for setInterval polling)
- TypeScript: registerHandle() and checkHandleResolution() wrappers in ipc.ts
- Svelte: HandleRegistrationScreen with registering → polling → success/timeout
phase machine; 4s poll interval, 2-minute timeout
- State machine: handle_registration step inserted between shamir_backup and complete

authored by

Malpercio and committed by
Tangled
f2a11a7a 1688dd11

+535 -2
+267
apps/identity-wallet/src-tauri/src/lib.rs
··· 162 162 NetworkError { message: String }, 163 163 } 164 164 165 + /// Subset of `GET /xrpc/com.atproto.server.describeServer` used internally. 166 + #[derive(Deserialize)] 167 + #[serde(rename_all = "camelCase")] 168 + struct DescribeServerResponse { 169 + available_user_domains: Vec<String>, 170 + } 171 + 172 + /// Request body for `POST /v1/handles`. 173 + #[derive(Serialize)] 174 + #[serde(rename_all = "camelCase")] 175 + struct CreateHandleRequest { 176 + account_id: String, 177 + handle: String, 178 + } 179 + 180 + /// Successful result returned to the Svelte frontend after handle registration. 181 + #[derive(Serialize)] 182 + #[serde(rename_all = "camelCase")] 183 + pub struct RegisterHandleResult { 184 + /// Full handle including domain, e.g. `alice.ezpds.com`. 185 + pub handle: String, 186 + /// `"propagating"` when DNS creation was requested; `"not_configured"` when no DNS provider 187 + /// is configured on the relay (handle still resolves via HTTP well-known). 188 + pub dns_status: String, 189 + } 190 + 191 + /// Typed error returned to the Svelte frontend as a rejected Promise. 192 + #[derive(Debug, Serialize, thiserror::Error)] 193 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 194 + pub enum RegisterHandleError { 195 + #[error("handle is already taken")] 196 + HandleTaken, 197 + #[error("handle format is invalid")] 198 + InvalidHandle, 199 + #[error("DNS record creation failed")] 200 + DnsError, 201 + #[error("keychain operation failed")] 202 + KeychainError, 203 + #[error("relay has no user domains configured")] 204 + NoDomains, 205 + #[error("network error: {message}")] 206 + NetworkError { message: String }, 207 + #[error("unknown error: {message}")] 208 + Unknown { message: String }, 209 + } 210 + 211 + /// Response shape from `GET /xrpc/com.atproto.identity.resolveHandle`. 212 + #[derive(Deserialize)] 213 + struct ResolveHandleResponse { 214 + did: String, 215 + } 216 + 165 217 // ── Static relay client ───────────────────────────────────────────────────── 166 218 167 219 static RELAY_CLIENT: LazyLock<http::RelayClient> = LazyLock::new(http::RelayClient::new); ··· 400 452 }) 401 453 } 402 454 455 + /// Register the user's handle with the relay and set up HTTP resolution. 456 + /// 457 + /// Fetches the relay's primary user domain via `GET /xrpc/com.atproto.server.describeServer`, 458 + /// constructs the full handle (`{handle_label}.{domain}`), reads the DID and session token 459 + /// from Keychain, then POSTs to `POST /v1/handles`. 460 + /// 461 + /// Returns the full handle and DNS propagation status on success. 462 + #[tauri::command] 463 + async fn register_handle( 464 + handle_label: String, 465 + ) -> Result<RegisterHandleResult, RegisterHandleError> { 466 + // Step 1: Fetch the relay's primary user domain. 467 + let resp = RELAY_CLIENT 468 + .get("/xrpc/com.atproto.server.describeServer") 469 + .await 470 + .map_err(|e| RegisterHandleError::NetworkError { 471 + message: e.to_string(), 472 + })?; 473 + 474 + if !resp.status().is_success() { 475 + return Err(RegisterHandleError::NetworkError { 476 + message: format!("describeServer returned HTTP {}", resp.status().as_u16()), 477 + }); 478 + } 479 + 480 + let server_info: DescribeServerResponse = 481 + resp.json() 482 + .await 483 + .map_err(|e| RegisterHandleError::Unknown { 484 + message: format!("failed to parse describeServer response: {e}"), 485 + })?; 486 + 487 + let domain = server_info 488 + .available_user_domains 489 + .into_iter() 490 + .next() 491 + .ok_or(RegisterHandleError::NoDomains)?; 492 + 493 + let full_handle = format!("{handle_label}.{domain}"); 494 + 495 + // Step 2: Read DID and session token from Keychain. 496 + let did_bytes = keychain::get_item("did").map_err(|e| { 497 + tracing::warn!(error = %e, "failed to read DID from Keychain during handle registration"); 498 + RegisterHandleError::KeychainError 499 + })?; 500 + let did = String::from_utf8(did_bytes).map_err(|e| { 501 + tracing::warn!(error = %e, "DID bytes are not valid UTF-8"); 502 + RegisterHandleError::KeychainError 503 + })?; 504 + 505 + let token_bytes = keychain::get_item("session-token").map_err(|e| { 506 + tracing::warn!(error = %e, "failed to read session-token from Keychain during handle registration"); 507 + RegisterHandleError::KeychainError 508 + })?; 509 + let session_token = String::from_utf8(token_bytes).map_err(|e| { 510 + tracing::warn!(error = %e, "session-token bytes are not valid UTF-8"); 511 + RegisterHandleError::KeychainError 512 + })?; 513 + 514 + // Step 3: POST to /v1/handles. 515 + let req = CreateHandleRequest { 516 + account_id: did, 517 + handle: full_handle.clone(), 518 + }; 519 + 520 + let resp = RELAY_CLIENT 521 + .post_with_bearer("/v1/handles", &req, &session_token) 522 + .await 523 + .map_err(|e| RegisterHandleError::NetworkError { 524 + message: e.to_string(), 525 + })?; 526 + 527 + let status = resp.status(); 528 + 529 + if status.is_success() { 530 + // Relay returns { "handle": "...", "dns_status": "...", "did": "..." }. 531 + // We only need handle and dns_status for the result. 532 + let body: serde_json::Value = 533 + resp.json() 534 + .await 535 + .map_err(|e| RegisterHandleError::Unknown { 536 + message: format!("failed to parse /v1/handles response: {e}"), 537 + })?; 538 + let dns_status = body["dns_status"] 539 + .as_str() 540 + .unwrap_or("not_configured") 541 + .to_string(); 542 + Ok(RegisterHandleResult { 543 + handle: full_handle, 544 + dns_status, 545 + }) 546 + } else { 547 + match status.as_u16() { 548 + 400 => { 549 + let envelope: RelayErrorEnvelope = 550 + resp.json().await.map_err(|e| RegisterHandleError::Unknown { 551 + message: e.to_string(), 552 + })?; 553 + if envelope.error.code == "INVALID_HANDLE" { 554 + Err(RegisterHandleError::InvalidHandle) 555 + } else { 556 + Err(RegisterHandleError::Unknown { 557 + message: format!("400: {}", envelope.error.code), 558 + }) 559 + } 560 + } 561 + 401 => Err(RegisterHandleError::KeychainError), 562 + 409 => Err(RegisterHandleError::HandleTaken), 563 + 502 => Err(RegisterHandleError::DnsError), 564 + other => Err(RegisterHandleError::NetworkError { 565 + message: format!("HTTP {other}"), 566 + }), 567 + } 568 + } 569 + } 570 + 571 + /// Check whether the relay can resolve `handle` to `expected_did` via the ATProto 572 + /// `resolveHandle` endpoint. 573 + /// 574 + /// Returns `true` when the relay resolves the handle to the expected DID (HTTP 200 + matching 575 + /// `did` field). Returns `false` for any other response (handle not yet propagated, relay 576 + /// unreachable, DID mismatch). Never rejects — callers can safely poll on an interval. 577 + #[tauri::command] 578 + async fn check_handle_resolution(handle: String, expected_did: String) -> bool { 579 + // ATProto handles are alphanumeric + hyphens + dots — all URL-safe; no percent-encoding needed. 580 + let path = format!("/xrpc/com.atproto.identity.resolveHandle?handle={handle}"); 581 + 582 + let resp = match RELAY_CLIENT.get(&path).await { 583 + Ok(r) => r, 584 + Err(e) => { 585 + tracing::debug!(error = %e, "check_handle_resolution: network error, returning false"); 586 + return false; 587 + } 588 + }; 589 + 590 + if !resp.status().is_success() { 591 + return false; 592 + } 593 + 594 + match resp.json::<ResolveHandleResponse>().await { 595 + Ok(body) => body.did == expected_did, 596 + Err(e) => { 597 + tracing::debug!(error = %e, "check_handle_resolution: failed to parse response, returning false"); 598 + false 599 + } 600 + } 601 + } 602 + 403 603 #[cfg_attr(mobile, tauri::mobile_entry_point)] 404 604 pub fn run() { 405 605 tauri::Builder::default() ··· 441 641 get_or_create_device_key, 442 642 sign_with_device_key, 443 643 perform_did_ceremony, 644 + register_handle, 645 + check_handle_resolution, 444 646 home::load_home_data, 445 647 home::log_out, 446 648 oauth::start_oauth_flow, ··· 591 793 let json = serde_json::to_value(map_409_subcode("UNKNOWN_SUBCODE")).unwrap(); 592 794 assert_eq!(json["code"], "UNKNOWN"); 593 795 assert!(json["message"].as_str().unwrap().contains("409:")); 796 + } 797 + 798 + // -- RegisterHandleResult serialization -- 799 + 800 + #[test] 801 + fn register_handle_result_serializes_camel_case() { 802 + let result = RegisterHandleResult { 803 + handle: "alice.ezpds.com".into(), 804 + dns_status: "propagating".into(), 805 + }; 806 + let json = serde_json::to_value(&result).unwrap(); 807 + assert_eq!(json["handle"], "alice.ezpds.com"); 808 + assert_eq!(json["dnsStatus"], "propagating"); 809 + } 810 + 811 + // -- RegisterHandleError serialization (one test per variant) -- 812 + 813 + #[test] 814 + fn register_handle_error_handle_taken_serializes_correctly() { 815 + let json = serde_json::to_value(&RegisterHandleError::HandleTaken).unwrap(); 816 + assert_eq!(json["code"], "HANDLE_TAKEN"); 817 + } 818 + 819 + #[test] 820 + fn register_handle_error_invalid_handle_serializes_correctly() { 821 + let json = serde_json::to_value(&RegisterHandleError::InvalidHandle).unwrap(); 822 + assert_eq!(json["code"], "INVALID_HANDLE"); 823 + } 824 + 825 + #[test] 826 + fn register_handle_error_dns_error_serializes_correctly() { 827 + let json = serde_json::to_value(&RegisterHandleError::DnsError).unwrap(); 828 + assert_eq!(json["code"], "DNS_ERROR"); 829 + } 830 + 831 + #[test] 832 + fn register_handle_error_keychain_error_serializes_correctly() { 833 + let json = serde_json::to_value(&RegisterHandleError::KeychainError).unwrap(); 834 + assert_eq!(json["code"], "KEYCHAIN_ERROR"); 835 + } 836 + 837 + #[test] 838 + fn register_handle_error_no_domains_serializes_correctly() { 839 + let json = serde_json::to_value(&RegisterHandleError::NoDomains).unwrap(); 840 + assert_eq!(json["code"], "NO_DOMAINS"); 841 + } 842 + 843 + #[test] 844 + fn register_handle_error_network_error_serializes_correctly() { 845 + let err = RegisterHandleError::NetworkError { 846 + message: "Connection refused".into(), 847 + }; 848 + let json = serde_json::to_value(&err).unwrap(); 849 + assert_eq!(json["code"], "NETWORK_ERROR"); 850 + assert_eq!(json["message"], "Connection refused"); 851 + } 852 + 853 + #[test] 854 + fn register_handle_error_unknown_serializes_correctly() { 855 + let err = RegisterHandleError::Unknown { 856 + message: "unexpected response".into(), 857 + }; 858 + let json = serde_json::to_value(&err).unwrap(); 859 + assert_eq!(json["code"], "UNKNOWN"); 860 + assert_eq!(json["message"], "unexpected response"); 594 861 } 595 862 596 863 // Tests the device_key contract that create_account depends on: the returned key
+205
apps/identity-wallet/src/lib/components/onboarding/HandleRegistrationScreen.svelte
··· 1 + <script lang="ts"> 2 + import { onDestroy, onMount } from 'svelte'; 3 + import LoadingScreen from './LoadingScreen.svelte'; 4 + import { registerHandle, checkHandleResolution, type RegisterHandleError } from '$lib/ipc'; 5 + 6 + let { 7 + handleLabel, 8 + did, 9 + onsuccess, 10 + ontimeout, 11 + }: { 12 + /** The label portion of the handle (e.g. `"alice"`), collected earlier in onboarding. */ 13 + handleLabel: string; 14 + /** The user's DID, used to verify HTTP resolution resolves to the correct identity. */ 15 + did: string; 16 + /** Called when the handle registers and HTTP resolution confirms the DID. */ 17 + onsuccess: (handle: string) => void; 18 + /** 19 + * Called when registration succeeded but HTTP resolution timed out after 2 minutes. 20 + * The handle is registered — DNS is just still propagating. 21 + * The caller should proceed and show a "still resolving" banner on the home screen. 22 + */ 23 + ontimeout: (handle: string) => void; 24 + } = $props(); 25 + 26 + type Phase = 27 + | { kind: 'registering' } 28 + | { kind: 'polling'; handle: string } 29 + | { kind: 'error'; error: RegisterHandleError }; 30 + 31 + let phase = $state<Phase>({ kind: 'registering' }); 32 + 33 + const POLL_INTERVAL_MS = 4_000; 34 + const POLL_TIMEOUT_MS = 120_000; 35 + 36 + let pollTimer: ReturnType<typeof setInterval> | undefined; 37 + let timeoutTimer: ReturnType<typeof setTimeout> | undefined; 38 + 39 + function stopPolling() { 40 + if (pollTimer !== undefined) { 41 + clearInterval(pollTimer); 42 + pollTimer = undefined; 43 + } 44 + if (timeoutTimer !== undefined) { 45 + clearTimeout(timeoutTimer); 46 + timeoutTimer = undefined; 47 + } 48 + } 49 + 50 + function startPolling(handle: string) { 51 + phase = { kind: 'polling', handle }; 52 + 53 + pollTimer = setInterval(async () => { 54 + const resolved = await checkHandleResolution(handle, did); 55 + if (resolved) { 56 + stopPolling(); 57 + onsuccess(handle); 58 + } 59 + }, POLL_INTERVAL_MS); 60 + 61 + timeoutTimer = setTimeout(() => { 62 + stopPolling(); 63 + ontimeout(handle); 64 + }, POLL_TIMEOUT_MS); 65 + } 66 + 67 + async function run() { 68 + phase = { kind: 'registering' }; 69 + stopPolling(); 70 + 71 + try { 72 + const result = await registerHandle(handleLabel); 73 + startPolling(result.handle); 74 + } catch (raw: unknown) { 75 + if ( 76 + typeof raw === 'object' && 77 + raw !== null && 78 + 'code' in raw && 79 + typeof (raw as RegisterHandleError).code === 'string' 80 + ) { 81 + phase = { kind: 'error', error: raw as RegisterHandleError }; 82 + } else { 83 + phase = { kind: 'error', error: { code: 'UNKNOWN', message: 'An unexpected error occurred.' } }; 84 + } 85 + } 86 + } 87 + 88 + function errorMessage(err: RegisterHandleError): string { 89 + switch (err.code) { 90 + case 'HANDLE_TAKEN': 91 + return 'That handle is already taken.'; 92 + case 'INVALID_HANDLE': 93 + return 'The handle format is invalid. Please go back and choose another.'; 94 + case 'DNS_ERROR': 95 + return 'Handle registered, but DNS setup failed. Please contact support.'; 96 + case 'NO_DOMAINS': 97 + return 'The relay has no handle domains configured. Please contact support.'; 98 + case 'KEYCHAIN_ERROR': 99 + return "Couldn't read your credentials. Please restart the app and try again."; 100 + case 'NETWORK_ERROR': 101 + default: 102 + return "Couldn't reach the server. Check your connection."; 103 + } 104 + } 105 + 106 + function canRetry(err: RegisterHandleError): boolean { 107 + return err.code !== 'INVALID_HANDLE' && err.code !== 'DNS_ERROR' && err.code !== 'NO_DOMAINS'; 108 + } 109 + 110 + onMount(() => run()); 111 + onDestroy(() => stopPolling()); 112 + </script> 113 + 114 + {#if phase.kind === 'registering'} 115 + <LoadingScreen statusText="Registering your handle…" /> 116 + {:else if phase.kind === 'polling'} 117 + <div class="screen"> 118 + <div class="handle-badge">{phase.handle}</div> 119 + <h2 class="title">Almost there!</h2> 120 + <p class="subtitle">Waiting for your handle to become active…</p> 121 + <div class="spinner" aria-label="Loading"></div> 122 + <p class="hint">This usually takes a few seconds.</p> 123 + </div> 124 + {:else if phase.kind === 'error'} 125 + <div class="screen"> 126 + <p class="error-text">{errorMessage(phase.error)}</p> 127 + {#if canRetry(phase.error)} 128 + <button class="retry" onclick={() => run()}>Retry</button> 129 + {/if} 130 + </div> 131 + {/if} 132 + 133 + <style> 134 + .screen { 135 + display: flex; 136 + flex-direction: column; 137 + align-items: center; 138 + justify-content: center; 139 + height: 100%; 140 + padding: 2rem; 141 + gap: 1rem; 142 + text-align: center; 143 + } 144 + 145 + .handle-badge { 146 + font-size: 1.1rem; 147 + font-weight: 600; 148 + color: #007aff; 149 + background: #eff6ff; 150 + border-radius: 8px; 151 + padding: 0.5rem 1rem; 152 + font-family: monospace; 153 + } 154 + 155 + .title { 156 + font-size: 1.5rem; 157 + font-weight: 700; 158 + margin: 0; 159 + color: #111827; 160 + } 161 + 162 + .subtitle { 163 + font-size: 0.95rem; 164 + color: #6b7280; 165 + margin: 0; 166 + } 167 + 168 + .hint { 169 + font-size: 0.8rem; 170 + color: #9ca3af; 171 + margin: 0; 172 + } 173 + 174 + .spinner { 175 + width: 32px; 176 + height: 32px; 177 + border: 3px solid #e5e7eb; 178 + border-top-color: #007aff; 179 + border-radius: 50%; 180 + animation: spin 0.8s linear infinite; 181 + } 182 + 183 + @keyframes spin { 184 + to { transform: rotate(360deg); } 185 + } 186 + 187 + .error-text { 188 + font-size: 1rem; 189 + color: #ef4444; 190 + margin: 0; 191 + } 192 + 193 + .retry { 194 + width: 100%; 195 + max-width: 320px; 196 + padding: 1rem; 197 + background: #007aff; 198 + color: #fff; 199 + border: none; 200 + border-radius: 12px; 201 + font-size: 1.1rem; 202 + font-weight: 600; 203 + cursor: pointer; 204 + } 205 + </style>
+51
apps/identity-wallet/src/lib/ipc.ts
··· 156 156 ): Promise<DIDCeremonyResult> => 157 157 invoke('perform_did_ceremony', { handle, password }); 158 158 159 + // ── register_handle ────────────────────────────────────────────────────────── 160 + 161 + /** 162 + * Successful result from the `register_handle` Rust command. 163 + * `handle` is the full `alice.your-domain.com` form. 164 + * `dnsStatus` is `"propagating"` when a DNS record was created, or `"not_configured"` when 165 + * the relay has no DNS provider (handle still resolves via HTTP well-known). 166 + */ 167 + export type RegisterHandleResult = { 168 + handle: string; 169 + dnsStatus: 'propagating' | 'not_configured'; 170 + }; 171 + 172 + /** 173 + * Error returned by the `register_handle` Rust command. 174 + * Serialized as `{ code: "HANDLE_TAKEN" }` etc. by the Rust backend. 175 + */ 176 + export type RegisterHandleError = { 177 + code: 178 + | 'HANDLE_TAKEN' 179 + | 'INVALID_HANDLE' 180 + | 'DNS_ERROR' 181 + | 'KEYCHAIN_ERROR' 182 + | 'NO_DOMAINS' 183 + | 'NETWORK_ERROR' 184 + | 'UNKNOWN'; 185 + message?: string; 186 + }; 187 + 188 + /** 189 + * Register the user's handle with the relay. 190 + * 191 + * `handleLabel` is the label portion only (e.g. `"alice"`). 192 + * The Rust backend fetches the relay's primary domain from `describeServer`, 193 + * reads the DID and session token from Keychain, and POSTs to `/v1/handles`. 194 + * 195 + * On failure, the Promise rejects with a `RegisterHandleError`. 196 + */ 197 + export const registerHandle = (handleLabel: string): Promise<RegisterHandleResult> => 198 + invoke('register_handle', { handleLabel }); 199 + 200 + /** 201 + * Check whether `handle` resolves to `expectedDid` via the relay's `resolveHandle` endpoint. 202 + * 203 + * Returns `true` when the relay resolves the handle to the expected DID. 204 + * Returns `false` for any other outcome (not yet propagated, relay unreachable, DID mismatch). 205 + * Never rejects — safe to call on a polling interval. 206 + */ 207 + export const checkHandleResolution = (handle: string, expectedDid: string): Promise<boolean> => 208 + invoke('check_handle_resolution', { handle, expectedDid }); 209 + 159 210 // ── OAuth ─────────────────────────────────────────────────────────────────── 160 211 // 161 212 // These variants must exactly match the Rust `OAuthError` enum in oauth.rs.
+12 -2
apps/identity-wallet/src/routes/+page.svelte
··· 10 10 import DIDCeremonyScreen from '$lib/components/onboarding/DIDCeremonyScreen.svelte'; 11 11 import DIDSuccessScreen from '$lib/components/onboarding/DIDSuccessScreen.svelte'; 12 12 import ShamirBackupScreen from '$lib/components/onboarding/ShamirBackupScreen.svelte'; 13 + import HandleRegistrationScreen from '$lib/components/onboarding/HandleRegistrationScreen.svelte'; 13 14 import AuthenticatingScreen from '$lib/components/onboarding/AuthenticatingScreen.svelte'; 14 15 import HomeScreen from '$lib/components/home/HomeScreen.svelte'; 15 16 import DIDDocumentScreen from '$lib/components/home/DIDDocumentScreen.svelte'; ··· 36 37 | 'did_ceremony' 37 38 | 'did_success' 38 39 | 'shamir_backup' 40 + | 'handle_registration' 39 41 | 'complete' 40 42 | 'authenticating' 41 43 | 'home' ··· 46 48 // ── State ──────────────────────────────────────────────────────────────── 47 49 48 50 let step = $state<OnboardingStep>('welcome'); 49 - let form = $state({ claimCode: '', email: '', handle: '', password: '', did: '', share3: '' }); 51 + let form = $state({ claimCode: '', email: '', handle: '', password: '', did: '', share3: '', registeredHandle: '' }); 50 52 51 53 /** 52 54 * Per-field error messages displayed by each screen. ··· 190 192 {:else if step === 'shamir_backup'} 191 193 <ShamirBackupScreen 192 194 share3={form.share3} 193 - oncomplete={() => { step = 'complete'; }} 195 + oncomplete={() => { step = 'handle_registration'; }} 194 196 /> 197 + {:else if step === 'handle_registration'} 198 + <HandleRegistrationScreen 199 + handleLabel={form.handle} 200 + did={form.did} 201 + onsuccess={(handle) => { form.registeredHandle = handle; step = 'complete'; }} 202 + ontimeout={(handle) => { form.registeredHandle = handle; step = 'complete'; }} 203 + /> 204 + 195 205 {:else if step === 'complete'} 196 206 <div class="complete"> 197 207 <div class="complete-icon" aria-hidden="true">✓</div>