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.

plans

+4102
+770
docs/implementation-plans/2026-03-27-MM-150/phase_01.md
··· 1 + # MM-150 Implementation Plan — Phase 1: Tauri home module and IPC wrappers 2 + 3 + **Goal:** Implement the Rust commands and TypeScript wrappers that expose the home screen data contract. 4 + 5 + **Architecture:** New `home.rs` module exposes two Tauri commands: `load_home_data` (concurrent relay health + getSession + Keychain check, always returns Ok) and `log_out` (Keychain wipe + AppState clear, always returns Ok). Registered in `lib.rs`; TypeScript wrappers added to `ipc.ts`. 6 + 7 + **Tech Stack:** Rust (tokio, serde_json, reqwest via OAuthClient/RelayClient), TypeScript 8 + 9 + **Scope:** Phase 1 of 6 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-150.AC2: Status indicators are accurate 18 + - **MM-150.AC2.1 Success:** Relay status shows Connected when `_health` returns 200 19 + - **MM-150.AC2.2 Failure:** Relay status shows Error when `_health` returns non-200 or network fails 20 + - **MM-150.AC2.3 Success:** Session status shows Active when `getSession` succeeds 21 + - **MM-150.AC2.4 Failure:** Session status shows Error when `getSession` fails after OAuthClient refresh attempt 22 + - **MM-150.AC2.5 Edge:** Relay and session statuses are independent — one can be error while the other is active 23 + 24 + ### MM-150.AC3: Three action flows work (logout subset) 25 + - **MM-150.AC3.3 Success:** Device key (`device-rotation-key-priv`) and DPoP key (`oauth-dpop-key-priv`) remain in Keychain after `log_out` 26 + 27 + ### MM-150.AC4: Tauri commands and IPC wrappers 28 + - **MM-150.AC4.1 Success:** `load_home_data` returns `relayHealthy: true` when `_health` returns 200 29 + - **MM-150.AC4.2 Success:** `load_home_data` returns populated `session` when `getSession` succeeds 30 + - **MM-150.AC4.3 Failure:** `load_home_data` returns `relayHealthy: false` (with `session` still populated) when `_health` fails 31 + - **MM-150.AC4.4 Failure:** `load_home_data` returns `session: null` and `sessionError` populated when `getSession` fails 32 + - **MM-150.AC4.5 Success:** `load_home_data` always returns `Ok(HomeData)` — never `Err` — regardless of partial sub-call failures 33 + - **MM-150.AC4.6 Success:** `log_out` deletes OAuth tokens and DID from Keychain 34 + - **MM-150.AC4.7 Success:** `log_out` always returns `Ok(())` even if Keychain delete partially fails 35 + 36 + ### MM-150.AC5: App launches to home when already onboarded (data contract only) 37 + - **MM-150.AC5.2 Success:** `homeData` is loaded on mount of `HomeScreen` regardless of entry path (startup or post-onboarding) 38 + 39 + --- 40 + 41 + <!-- START_SUBCOMPONENT_A (tasks 0-4) --> 42 + <!-- START_TASK_0 --> 43 + ### Task 0: Add `Clone` derive to `OAuthSession` in `oauth.rs` 44 + 45 + **Verifies:** (prerequisite — enables `home.rs` to compile) 46 + 47 + **Files:** 48 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` 49 + 50 + **Why:** `home.rs` clones `OAuthSession` out of `AppState` (to release the Mutex lock before async calls) and again after the HTTP response to write refreshed tokens back into `AppState`. This requires `OAuthSession: Clone`. All four fields (`String`, `String`, `u64`, `Option<String>`) already implement `Clone`. 51 + 52 + **Implementation:** 53 + 54 + In `apps/identity-wallet/src-tauri/src/oauth.rs`, find the `OAuthSession` struct at line 268. Add `#[derive(Clone)]` above the struct definition. 55 + 56 + Before: 57 + ```rust 58 + /// Active OAuth session stored in AppState after successful token exchange. 59 + pub struct OAuthSession { 60 + ``` 61 + 62 + After: 63 + ```rust 64 + /// Active OAuth session stored in AppState after successful token exchange. 65 + #[derive(Clone)] 66 + pub struct OAuthSession { 67 + ``` 68 + 69 + **Verification:** 70 + Run: `cargo build -p identity-wallet` 71 + Expected: Compiles without errors (note: `home.rs` doesn't exist yet — this just checks that `OAuthSession: Clone` compiles) 72 + 73 + **Commit:** (defer — commit alongside home.rs in Task 3) 74 + <!-- END_TASK_0 --> 75 + 76 + <!-- START_TASK_1 --> 77 + ### Task 1: Create `home.rs` with types and `load_home_data` command 78 + 79 + **Verifies:** MM-150.AC4.1, MM-150.AC4.2, MM-150.AC4.3, MM-150.AC4.4, MM-150.AC4.5, MM-150.AC2.1, MM-150.AC2.2, MM-150.AC2.3, MM-150.AC2.4, MM-150.AC2.5 80 + 81 + **Files:** 82 + - Create: `apps/identity-wallet/src-tauri/src/home.rs` 83 + 84 + **Implementation:** 85 + 86 + Create `apps/identity-wallet/src-tauri/src/home.rs` with the following content: 87 + 88 + ```rust 89 + // pattern: Imperative Shell 90 + // 91 + // Gathers: AppState (oauth_session), RelayClient, OAuthClient 92 + // Processes: concurrent _health + getSession + Keychain check 93 + // Returns: HomeData (always Ok — partial failures encoded as fields) 94 + 95 + use std::sync::{Arc, Mutex}; 96 + 97 + use serde_json::Value; 98 + 99 + use crate::oauth::{AppState, OAuthError}; 100 + 101 + // ── Wire types: ATProto getSession response ──────────────────────────────── 102 + 103 + #[derive(Debug, serde::Deserialize)] 104 + #[serde(rename_all = "camelCase")] 105 + struct GetSessionResponse { 106 + did: String, 107 + handle: String, 108 + #[serde(default)] 109 + email: String, 110 + #[serde(default)] 111 + email_confirmed: bool, 112 + did_doc: Option<Value>, 113 + } 114 + 115 + // ── Output types: sent to frontend via Tauri IPC ────────────────────────── 116 + 117 + /// Session info from com.atproto.server.getSession, forwarded to the frontend. 118 + #[derive(Debug, serde::Serialize)] 119 + #[serde(rename_all = "camelCase")] 120 + pub struct SessionInfo { 121 + pub did: String, 122 + pub handle: String, 123 + pub email: String, 124 + pub email_confirmed: bool, 125 + pub did_doc: Option<Value>, 126 + } 127 + 128 + /// Home screen data payload. Always returned as Ok — partial failures 129 + /// (relay unreachable, session expired) are encoded as fields so the UI 130 + /// can render whatever is available. 131 + #[derive(Debug, serde::Serialize)] 132 + #[serde(rename_all = "camelCase")] 133 + pub struct HomeData { 134 + pub relay_healthy: bool, 135 + /// null when getSession failed or no session exists in AppState 136 + pub session: Option<SessionInfo>, 137 + /// SCREAMING_SNAKE_CASE error code when session is null 138 + pub session_error: Option<String>, 139 + pub share1_in_keychain: bool, 140 + } 141 + 142 + // ── Commands ────────────────────────────────────────────────────────────── 143 + 144 + /// Load home screen data: relay health, session info, and Keychain share status. 145 + /// 146 + /// Fires GET /xrpc/_health and GET /xrpc/com.atproto.server.getSession 147 + /// concurrently via tokio::join!. Always succeeds — partial failures are 148 + /// encoded in HomeData fields rather than returned as Err. 149 + #[tauri::command] 150 + pub async fn load_home_data(state: tauri::State<'_, AppState>) -> HomeData { 151 + let share1_in_keychain = crate::keychain::get_item("recovery-share-1").is_ok(); 152 + 153 + // Clone session out of AppState (drops the lock immediately). 154 + let session_opt = { 155 + let guard = state.oauth_session.lock().unwrap(); 156 + guard.clone() 157 + }; 158 + 159 + let Some(session) = session_opt else { 160 + let relay_healthy = check_relay_health().await; 161 + return HomeData { 162 + relay_healthy, 163 + session: None, 164 + session_error: Some("NOT_AUTHENTICATED".to_string()), 165 + share1_in_keychain, 166 + }; 167 + }; 168 + 169 + let session_arc = Arc::new(Mutex::new(session)); 170 + 171 + let oauth_client = match crate::oauth_client::OAuthClient::new(session_arc.clone()) { 172 + Ok(c) => c, 173 + Err(e) => { 174 + return HomeData { 175 + relay_healthy: check_relay_health().await, 176 + session: None, 177 + session_error: Some(oauth_error_code(&e)), 178 + share1_in_keychain, 179 + }; 180 + } 181 + }; 182 + 183 + let (relay_healthy, session_result) = tokio::join!( 184 + check_relay_health(), 185 + oauth_client.get("/xrpc/com.atproto.server.getSession"), 186 + ); 187 + 188 + let (session_info, session_error) = match session_result { 189 + Ok(resp) if resp.status().is_success() => { 190 + match resp.json::<GetSessionResponse>().await { 191 + Ok(gs) => { 192 + // Write back potentially-refreshed tokens to AppState. 193 + let refreshed = session_arc.lock().unwrap().clone(); 194 + *state.oauth_session.lock().unwrap() = Some(refreshed); 195 + ( 196 + Some(SessionInfo { 197 + did: gs.did, 198 + handle: gs.handle, 199 + email: gs.email, 200 + email_confirmed: gs.email_confirmed, 201 + did_doc: gs.did_doc, 202 + }), 203 + None, 204 + ) 205 + } 206 + Err(e) => { 207 + tracing::error!(error = %e, "getSession deserialization failed"); 208 + (None, Some("SESSION_PARSE_ERROR".to_string())) 209 + } 210 + } 211 + } 212 + Ok(resp) => { 213 + tracing::warn!(status = %resp.status(), "getSession returned non-success"); 214 + (None, Some("NOT_AUTHENTICATED".to_string())) 215 + } 216 + Err(e) => (None, Some(oauth_error_code(&e))), 217 + }; 218 + 219 + HomeData { 220 + relay_healthy, 221 + session: session_info, 222 + session_error, 223 + share1_in_keychain, 224 + } 225 + } 226 + 227 + // ── Private helpers ─────────────────────────────────────────────────────── 228 + 229 + /// Creates a new RelayClient on each call. Acceptable because load_home_data 230 + /// is invoked at most once per user-initiated home screen refresh; the cost is 231 + /// not significant at this call frequency. 232 + async fn check_relay_health() -> bool { 233 + crate::http::RelayClient::new() 234 + .get("/xrpc/_health") 235 + .await 236 + .map(|r| r.status().is_success()) 237 + .unwrap_or(false) 238 + } 239 + 240 + fn oauth_error_code(e: &OAuthError) -> String { 241 + serde_json::to_value(e) 242 + .ok() 243 + .and_then(|v| v["code"].as_str().map(String::from)) 244 + .unwrap_or_else(|| "UNKNOWN".to_string()) 245 + } 246 + ``` 247 + 248 + **Verification:** 249 + Run: `cargo build -p identity-wallet` 250 + Expected: Compiles without errors or warnings (you will also add `log_out` in Task 2 before this fully compiles — `home` module must be declared in `lib.rs` first, see Task 2) 251 + 252 + **Commit:** (defer until after Task 3 — all Phase 1 files in one commit) 253 + <!-- END_TASK_1 --> 254 + 255 + <!-- START_TASK_2 --> 256 + ### Task 2: Add `log_out` command and register module in `lib.rs` 257 + 258 + **Verifies:** MM-150.AC4.6, MM-150.AC4.7 259 + 260 + **Files:** 261 + - Modify: `apps/identity-wallet/src-tauri/src/home.rs` (append) 262 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 263 + 264 + **Implementation — append to `home.rs`:** 265 + 266 + Add `log_out` after the `load_home_data` function (before the private helpers section or after it): 267 + 268 + ```rust 269 + /// Clear OAuth tokens and DID from Keychain and wipe the in-memory session. 270 + /// 271 + /// Always succeeds — Keychain delete errors are swallowed so the frontend 272 + /// unconditionally navigates to the welcome screen. 273 + #[tauri::command] 274 + pub async fn log_out(state: tauri::State<'_, AppState>) { 275 + let _ = crate::keychain::delete_item("oauth-access-token"); 276 + let _ = crate::keychain::delete_item("oauth-refresh-token"); 277 + let _ = crate::keychain::delete_item("did"); 278 + *state.oauth_session.lock().unwrap() = None; 279 + } 280 + ``` 281 + 282 + **Implementation — modify `lib.rs`:** 283 + 284 + 1. Add `pub mod home;` to the module declarations at the top of `lib.rs`. Insert it alphabetically among the existing `pub mod` lines (lines 1–5). After the edit the block should read: 285 + 286 + ```rust 287 + pub mod device_key; 288 + pub mod home; 289 + pub mod http; 290 + pub mod keychain; 291 + pub mod oauth; 292 + pub mod oauth_client; 293 + ``` 294 + 295 + 2. Register the two new commands in `generate_handler![]` at line 438. After the edit the block should read: 296 + 297 + ```rust 298 + .invoke_handler(tauri::generate_handler![ 299 + create_account, 300 + get_or_create_device_key, 301 + sign_with_device_key, 302 + perform_did_ceremony, 303 + home::load_home_data, 304 + home::log_out, 305 + oauth::start_oauth_flow, 306 + ]) 307 + ``` 308 + 309 + **Verification:** 310 + Run: `cargo build -p identity-wallet` 311 + Expected: Compiles without errors or warnings 312 + 313 + **Commit:** (defer until after Task 3) 314 + <!-- END_TASK_2 --> 315 + 316 + <!-- START_TASK_3 --> 317 + ### Task 3: Add `loadHomeData`, `logOut`, and types to `ipc.ts` 318 + 319 + **Verifies:** MM-150.AC4.1–AC4.7 (TypeScript contracts) 320 + 321 + **Files:** 322 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` (append at end) 323 + 324 + **Implementation:** 325 + 326 + Append the following section to the end of `apps/identity-wallet/src/lib/ipc.ts`: 327 + 328 + ```typescript 329 + // ── Home screen ────────────────────────────────────────────────────────── 330 + // 331 + // These types must exactly match the Rust structs in home.rs. 332 + // Rust serializes them with #[serde(rename_all = "camelCase")]. 333 + 334 + /** 335 + * Session info returned by com.atproto.server.getSession. 336 + * null fields (email, emailConfirmed) default to empty string / false 337 + * when the relay omits them. 338 + */ 339 + export type SessionInfo = { 340 + did: string; 341 + handle: string; 342 + email: string; 343 + emailConfirmed: boolean; 344 + /** Full DID document object, or null when the relay has none for this DID. */ 345 + didDoc: Record<string, unknown> | null; 346 + }; 347 + 348 + /** 349 + * Home screen data payload from the `load_home_data` Rust command. 350 + * 351 + * Always resolves (never rejects) — partial failures are encoded as fields 352 + * so the UI can render whatever is available. 353 + */ 354 + export type HomeData = { 355 + relayHealthy: boolean; 356 + /** null when getSession failed or no session exists */ 357 + session: SessionInfo | null; 358 + /** SCREAMING_SNAKE_CASE error code when session is null */ 359 + sessionError: string | null; 360 + share1InKeychain: boolean; 361 + }; 362 + 363 + /** 364 + * Load relay health, session info, and Keychain share status concurrently. 365 + * 366 + * Always resolves — never rejects. Partial failures encoded in HomeData fields. 367 + */ 368 + export const loadHomeData = (): Promise<HomeData> => invoke('load_home_data'); 369 + 370 + /** 371 + * Clear OAuth access token, refresh token, and DID from Keychain and wipe 372 + * the in-memory session. 373 + * 374 + * Always resolves. Frontend should unconditionally navigate to the welcome screen. 375 + */ 376 + export const logOut = (): Promise<void> => invoke('log_out'); 377 + ``` 378 + 379 + **Verification:** 380 + Run from `apps/identity-wallet/`: `pnpm check` (or `pnpm exec svelte-check`) 381 + Expected: No TypeScript errors 382 + 383 + **Commit:** 384 + ```bash 385 + git add apps/identity-wallet/src-tauri/src/home.rs \ 386 + apps/identity-wallet/src-tauri/src/lib.rs \ 387 + apps/identity-wallet/src/lib/ipc.ts 388 + git commit -m "feat: add load_home_data and log_out Tauri commands with IPC wrappers" 389 + ``` 390 + <!-- END_TASK_3 --> 391 + 392 + <!-- START_TASK_4 --> 393 + ### Task 4: Unit tests for `home.rs` 394 + 395 + **Verifies:** MM-150.AC3.3, MM-150.AC4.1, MM-150.AC4.2, MM-150.AC4.3, MM-150.AC4.4, MM-150.AC4.5, MM-150.AC4.6, MM-150.AC4.7, MM-150.AC2.1, MM-150.AC2.2, MM-150.AC2.3, MM-150.AC2.5 396 + 397 + **Files:** 398 + - Modify: `apps/identity-wallet/src-tauri/src/home.rs` (append test module) 399 + 400 + **Implementation:** 401 + 402 + Tests are split into two groups: 403 + 1. **Serialization tests** (sync): verify `HomeData` and `SessionInfo` serialize to the camelCase shapes expected by TypeScript. 404 + 2. **`log_out` Keychain tests** (async): verify Keychain items are deleted and AppState is cleared. 405 + 3. **`load_home_data` helper tests** (async, with httpmock): verify correct fields for relay-up/down and session-ok/fail cases. 406 + 407 + For the HTTP tests, extract a private helper `load_home_data_with_urls` that accepts relay and OAuth base URLs, allowing tests to inject a mock server URL. This is the same pattern as `OAuthClient::new_for_test`. 408 + 409 + Append to `apps/identity-wallet/src-tauri/src/home.rs`: 410 + 411 + ```rust 412 + // ── Test helper: injectable base URLs ───────────────────────────────────── 413 + 414 + #[cfg(test)] 415 + async fn load_home_data_with_urls( 416 + relay_base: &str, 417 + oauth_base: &str, 418 + session: Option<crate::oauth::OAuthSession>, 419 + app_state: &AppState, 420 + ) -> HomeData { 421 + let share1_in_keychain = crate::keychain::get_item("recovery-share-1").is_ok(); 422 + 423 + let Some(s) = session else { 424 + let relay_healthy = reqwest::Client::new() 425 + .get(format!("{}/xrpc/_health", relay_base)) 426 + .send() 427 + .await 428 + .map(|r| r.status().is_success()) 429 + .unwrap_or(false); 430 + return HomeData { 431 + relay_healthy, 432 + session: None, 433 + session_error: Some("NOT_AUTHENTICATED".to_string()), 434 + share1_in_keychain, 435 + }; 436 + }; 437 + 438 + let session_arc = Arc::new(Mutex::new(s)); 439 + 440 + let dpop = crate::oauth::DPoPKeypair::get_or_create().expect("keypair must exist"); 441 + let oauth_client = 442 + crate::oauth_client::OAuthClient::new_for_test(dpop, session_arc.clone(), oauth_base.to_string()); 443 + 444 + let relay_client = reqwest::Client::new(); 445 + let (health_result, session_result) = tokio::join!( 446 + relay_client 447 + .get(format!("{}/xrpc/_health", relay_base)) 448 + .send(), 449 + oauth_client.get("/xrpc/com.atproto.server.getSession"), 450 + ); 451 + 452 + let relay_healthy = health_result 453 + .map(|r| r.status().is_success()) 454 + .unwrap_or(false); 455 + 456 + let (session_info, session_error) = match session_result { 457 + Ok(resp) if resp.status().is_success() => { 458 + match resp.json::<GetSessionResponse>().await { 459 + Ok(gs) => { 460 + let refreshed = session_arc.lock().unwrap().clone(); 461 + *app_state.oauth_session.lock().unwrap() = Some(refreshed); 462 + ( 463 + Some(SessionInfo { 464 + did: gs.did, 465 + handle: gs.handle, 466 + email: gs.email, 467 + email_confirmed: gs.email_confirmed, 468 + did_doc: gs.did_doc, 469 + }), 470 + None, 471 + ) 472 + } 473 + Err(_) => (None, Some("SESSION_PARSE_ERROR".to_string())), 474 + } 475 + } 476 + Ok(resp) => { 477 + let _status = resp.status().as_u16(); 478 + (None, Some("NOT_AUTHENTICATED".to_string())) 479 + } 480 + Err(e) => (None, Some(oauth_error_code(&e))), 481 + }; 482 + 483 + HomeData { 484 + relay_healthy, 485 + session: session_info, 486 + session_error, 487 + share1_in_keychain, 488 + } 489 + } 490 + 491 + #[cfg(test)] 492 + mod tests { 493 + use super::*; 494 + use crate::oauth::{AppState, OAuthSession}; 495 + use httpmock::prelude::*; 496 + 497 + fn make_session(access: &str) -> OAuthSession { 498 + OAuthSession { 499 + access_token: access.to_string(), 500 + refresh_token: "refresh".to_string(), 501 + expires_at: u64::MAX, // never expires 502 + dpop_nonce: None, 503 + } 504 + } 505 + 506 + // ── Serialization ────────────────────────────────────────────────────── 507 + 508 + #[test] 509 + fn home_data_serializes_camel_case() { 510 + let data = HomeData { 511 + relay_healthy: true, 512 + session: Some(SessionInfo { 513 + did: "did:plc:abc".into(), 514 + handle: "alice.test".into(), 515 + email: "alice@example.com".into(), 516 + email_confirmed: true, 517 + did_doc: None, 518 + }), 519 + session_error: None, 520 + share1_in_keychain: true, 521 + }; 522 + let json = serde_json::to_value(&data).unwrap(); 523 + assert_eq!(json["relayHealthy"], true); 524 + assert_eq!(json["session"]["did"], "did:plc:abc"); 525 + assert_eq!(json["session"]["handle"], "alice.test"); 526 + assert_eq!(json["session"]["emailConfirmed"], true); 527 + assert_eq!(json["sessionError"], serde_json::Value::Null); 528 + assert_eq!(json["share1InKeychain"], true); 529 + } 530 + 531 + #[test] 532 + fn home_data_session_null_serializes_error_code() { 533 + let data = HomeData { 534 + relay_healthy: false, 535 + session: None, 536 + session_error: Some("NOT_AUTHENTICATED".to_string()), 537 + share1_in_keychain: false, 538 + }; 539 + let json = serde_json::to_value(&data).unwrap(); 540 + assert_eq!(json["session"], serde_json::Value::Null); 541 + assert_eq!(json["sessionError"], "NOT_AUTHENTICATED"); 542 + assert_eq!(json["relayHealthy"], false); 543 + } 544 + 545 + // ── log_out Keychain behavior ────────────────────────────────────────── 546 + 547 + /// Store the three OAuth items that log_out must delete. 548 + fn store_oauth_keychain_items() { 549 + crate::keychain::store_item("oauth-access-token", b"access").unwrap(); 550 + crate::keychain::store_item("oauth-refresh-token", b"refresh").unwrap(); 551 + crate::keychain::store_item("did", b"did:plc:abc").unwrap(); 552 + } 553 + 554 + /// Execute the same Keychain + AppState wipe that log_out performs. 555 + /// Used in tests because Tauri commands can't be called without an app handle. 556 + fn simulate_log_out(state: &AppState) { 557 + let _ = crate::keychain::delete_item("oauth-access-token"); 558 + let _ = crate::keychain::delete_item("oauth-refresh-token"); 559 + let _ = crate::keychain::delete_item("did"); 560 + *state.oauth_session.lock().unwrap() = None; 561 + } 562 + 563 + #[tokio::test] 564 + async fn log_out_deletes_oauth_and_did_from_keychain() { 565 + store_oauth_keychain_items(); 566 + let state = AppState::new(); 567 + *state.oauth_session.lock().unwrap() = Some(make_session("access")); 568 + simulate_log_out(&state); 569 + assert!(crate::keychain::get_item("oauth-access-token").is_err()); 570 + assert!(crate::keychain::get_item("oauth-refresh-token").is_err()); 571 + assert!(crate::keychain::get_item("did").is_err()); 572 + assert!(state.oauth_session.lock().unwrap().is_none()); 573 + } 574 + 575 + #[tokio::test] 576 + async fn log_out_succeeds_when_keychain_items_absent() { 577 + // Items may not exist — log_out must not panic. AC4.7. 578 + let state = AppState::new(); 579 + simulate_log_out(&state); 580 + } 581 + 582 + #[tokio::test] 583 + async fn log_out_preserves_device_and_dpop_keys() { 584 + // Store OAuth items AND keys that must survive logout. 585 + store_oauth_keychain_items(); 586 + crate::keychain::store_item("oauth-dpop-key-priv", b"dpop-key-bytes").unwrap(); 587 + crate::keychain::store_item("device-rotation-key-priv", b"device-key-bytes").unwrap(); 588 + 589 + let state = AppState::new(); 590 + simulate_log_out(&state); 591 + 592 + // OAuth items gone. 593 + assert!(crate::keychain::get_item("oauth-access-token").is_err()); 594 + // Device and DPoP keys must NOT have been deleted (AC3.3). 595 + assert!( 596 + crate::keychain::get_item("oauth-dpop-key-priv").is_ok(), 597 + "DPoP key must remain after logout" 598 + ); 599 + assert!( 600 + crate::keychain::get_item("device-rotation-key-priv").is_ok(), 601 + "device key must remain after logout" 602 + ); 603 + 604 + // Cleanup so other tests are not affected. 605 + let _ = crate::keychain::delete_item("oauth-dpop-key-priv"); 606 + let _ = crate::keychain::delete_item("device-rotation-key-priv"); 607 + } 608 + 609 + // ── load_home_data: unauthenticated path ─────────────────────────────── 610 + 611 + #[tokio::test] 612 + async fn load_home_data_no_session_returns_not_authenticated() { 613 + let server = MockServer::start(); 614 + server.mock(|when, then| { 615 + when.method(GET).path("/xrpc/_health"); 616 + then.status(200).body(r#"{"version":"0.1.0"}"#); 617 + }); 618 + 619 + let state = AppState::new(); // no oauth_session 620 + let data = 621 + load_home_data_with_urls(&server.base_url(), &server.base_url(), None, &state).await; 622 + 623 + assert!(data.relay_healthy); 624 + assert!(data.session.is_none()); 625 + assert_eq!(data.session_error.as_deref(), Some("NOT_AUTHENTICATED")); 626 + } 627 + 628 + // ── load_home_data: relay health (AC4.1, AC4.3, AC2.1, AC2.2) ───────── 629 + 630 + #[tokio::test] 631 + async fn load_home_data_relay_healthy_true_when_health_returns_200() { 632 + let server = MockServer::start(); 633 + server.mock(|when, then| { 634 + when.method(GET).path("/xrpc/_health"); 635 + then.status(200).body(r#"{"version":"0.1.0"}"#); 636 + }); 637 + server.mock(|when, then| { 638 + when.method(GET).path("/xrpc/com.atproto.server.getSession"); 639 + then.status(200).json_body(serde_json::json!({ 640 + "did": "did:plc:abc", 641 + "handle": "alice.test", 642 + "email": "alice@example.com", 643 + "emailConfirmed": true, 644 + "didDoc": null 645 + })); 646 + }); 647 + 648 + let state = AppState::new(); 649 + let data = load_home_data_with_urls( 650 + &server.base_url(), 651 + &server.base_url(), 652 + Some(make_session("access")), 653 + &state, 654 + ) 655 + .await; 656 + 657 + assert!(data.relay_healthy, "relay_healthy must be true when _health returns 200"); 658 + } 659 + 660 + #[tokio::test] 661 + async fn load_home_data_relay_healthy_false_when_health_fails() { 662 + let server = MockServer::start(); 663 + server.mock(|when, then| { 664 + when.method(GET).path("/xrpc/_health"); 665 + then.status(503); 666 + }); 667 + server.mock(|when, then| { 668 + when.method(GET).path("/xrpc/com.atproto.server.getSession"); 669 + then.status(200).json_body(serde_json::json!({ 670 + "did": "did:plc:abc", 671 + "handle": "alice.test", 672 + "email": "", 673 + "emailConfirmed": false, 674 + "didDoc": null 675 + })); 676 + }); 677 + 678 + let state = AppState::new(); 679 + let data = load_home_data_with_urls( 680 + &server.base_url(), 681 + &server.base_url(), 682 + Some(make_session("access")), 683 + &state, 684 + ) 685 + .await; 686 + 687 + assert!(!data.relay_healthy, "relay_healthy must be false when _health returns 503"); 688 + // Session can still be populated (AC2.5: statuses are independent) 689 + assert!(data.session.is_some(), "session should still be populated when relay fails"); 690 + } 691 + 692 + // ── load_home_data: session (AC4.2, AC4.4, AC2.3, AC2.4) ────────────── 693 + 694 + #[tokio::test] 695 + async fn load_home_data_session_populated_when_get_session_succeeds() { 696 + let server = MockServer::start(); 697 + server.mock(|when, then| { 698 + when.method(GET).path("/xrpc/_health"); 699 + then.status(200).body(r#"{"version":"0.1.0"}"#); 700 + }); 701 + server.mock(|when, then| { 702 + when.method(GET).path("/xrpc/com.atproto.server.getSession"); 703 + then.status(200).json_body(serde_json::json!({ 704 + "did": "did:plc:xyz123", 705 + "handle": "bob.test", 706 + "email": "bob@example.com", 707 + "emailConfirmed": false, 708 + "didDoc": null 709 + })); 710 + }); 711 + 712 + let state = AppState::new(); 713 + let data = load_home_data_with_urls( 714 + &server.base_url(), 715 + &server.base_url(), 716 + Some(make_session("access")), 717 + &state, 718 + ) 719 + .await; 720 + 721 + let session = data.session.expect("session must be populated"); 722 + assert_eq!(session.did, "did:plc:xyz123"); 723 + assert_eq!(session.handle, "bob.test"); 724 + assert_eq!(session.email, "bob@example.com"); 725 + assert_eq!(session.email_confirmed, false); 726 + assert!(data.session_error.is_none()); 727 + } 728 + 729 + #[tokio::test] 730 + async fn load_home_data_session_null_when_get_session_fails() { 731 + let server = MockServer::start(); 732 + server.mock(|when, then| { 733 + when.method(GET).path("/xrpc/_health"); 734 + then.status(200).body(r#"{"version":"0.1.0"}"#); 735 + }); 736 + server.mock(|when, then| { 737 + when.method(GET).path("/xrpc/com.atproto.server.getSession"); 738 + then.status(401); 739 + }); 740 + 741 + let state = AppState::new(); 742 + let data = load_home_data_with_urls( 743 + &server.base_url(), 744 + &server.base_url(), 745 + Some(make_session("access")), 746 + &state, 747 + ) 748 + .await; 749 + 750 + assert!(data.session.is_none()); 751 + assert!(data.session_error.is_some(), "sessionError must be set when getSession fails"); 752 + // relay is still healthy (AC2.5: independent statuses) 753 + assert!(data.relay_healthy); 754 + } 755 + } 756 + ``` 757 + 758 + **Note on test structure:** `load_home_data_with_urls` is a `#[cfg(test)]` helper that mirrors the production logic but accepts injectable base URLs. This follows the same pattern as `OAuthClient::new_for_test` in `oauth_client.rs`. The production `load_home_data` Tauri command continues to use `RelayClient::base_url()` and `OAuthClient::new()`. 759 + 760 + **Verification:** 761 + Run: `cargo test -p identity-wallet` 762 + Expected: All tests pass 763 + 764 + **Commit:** 765 + ```bash 766 + git add apps/identity-wallet/src-tauri/src/home.rs 767 + git commit -m "test: add unit tests for load_home_data and log_out in home.rs" 768 + ``` 769 + <!-- END_TASK_4 --> 770 + <!-- END_SUBCOMPONENT_A -->
+104
docs/implementation-plans/2026-03-27-MM-150/phase_02.md
··· 1 + # MM-150 Implementation Plan — Phase 2: DIDAvatar component 2 + 3 + **Goal:** Standalone, deterministic avatar component showing a stable gradient circle and handle initial for any DID. 4 + 5 + **Architecture:** Pure Svelte 5 component with no side effects. Derives hue from a simple integer hash of the DID string; derives the initial letter from the handle (or `?` for `handle.invalid`). No external dependencies, no IPC calls. 6 + 7 + **Tech Stack:** Svelte 5 (`$props()`, `$derived`) 8 + 9 + **Scope:** Phase 2 of 6 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-150.AC1: Identity card displays correctly 18 + - **MM-150.AC1.5 Success:** DID-derived avatar circle is visible with a stable hue derived from the DID hash 19 + - **MM-150.AC1.6 Success:** Avatar shows the first letter of the handle as its initial 20 + - **MM-150.AC1.7 Edge:** Avatar shows `?` when handle is `handle.invalid` 21 + 22 + --- 23 + 24 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 25 + <!-- START_TASK_1 --> 26 + ### Task 1: Create `DIDAvatar.svelte` 27 + 28 + **Verifies:** MM-150.AC1.5, MM-150.AC1.6, MM-150.AC1.7 29 + 30 + **Files:** 31 + - Create: `apps/identity-wallet/src/lib/components/home/DIDAvatar.svelte` 32 + 33 + **Implementation:** 34 + 35 + Create the directory first (it doesn't exist yet): 36 + 37 + ```bash 38 + mkdir -p apps/identity-wallet/src/lib/components/home 39 + ``` 40 + 41 + Then create `apps/identity-wallet/src/lib/components/home/DIDAvatar.svelte`: 42 + 43 + ```svelte 44 + <script lang="ts"> 45 + let { 46 + did, 47 + handle, 48 + }: { 49 + did: string; 50 + handle: string; 51 + } = $props(); 52 + 53 + // Derive a stable hue (0-359) from the DID string using a simple polynomial hash. 54 + // The same DID always produces the same hue across re-renders and app sessions. 55 + let hue = $derived.by(() => { 56 + let h = 0; 57 + for (let i = 0; i < did.length; i++) { 58 + h = (h * 31 + did.charCodeAt(i)) & 0xffffff; 59 + } 60 + return h % 360; 61 + }); 62 + 63 + // Show '?' for the ATProto sentinel value that means "no handle registered". 64 + let initial = $derived( 65 + handle === 'handle.invalid' ? '?' : handle.charAt(0).toUpperCase() 66 + ); 67 + </script> 68 + 69 + <div 70 + class="avatar" 71 + style="background: hsl({hue}, 65%, 45%)" 72 + aria-label="Avatar for {handle}" 73 + > 74 + {initial} 75 + </div> 76 + 77 + <style> 78 + .avatar { 79 + width: 64px; 80 + height: 64px; 81 + border-radius: 50%; 82 + display: flex; 83 + align-items: center; 84 + justify-content: center; 85 + color: #fff; 86 + font-size: 1.75rem; 87 + font-weight: 700; 88 + flex-shrink: 0; 89 + user-select: none; 90 + } 91 + </style> 92 + ``` 93 + 94 + **Verification:** 95 + Run from `apps/identity-wallet/`: `pnpm check` 96 + Expected: No TypeScript errors 97 + 98 + **Commit:** 99 + ```bash 100 + git add apps/identity-wallet/src/lib/components/home/DIDAvatar.svelte 101 + git commit -m "feat: add DIDAvatar component with deterministic DID-derived hue" 102 + ``` 103 + <!-- END_TASK_1 --> 104 + <!-- END_SUBCOMPONENT_A -->
+425
docs/implementation-plans/2026-03-27-MM-150/phase_03.md
··· 1 + # MM-150 Implementation Plan — Phase 3: HomeScreen component 2 + 3 + **Goal:** Main home screen displaying identity card, relay/session status indicators, and action buttons. 4 + 5 + **Architecture:** Svelte 5 component. Calls `loadHomeData()` from `$lib/ipc` on mount and on refresh button tap. Shows a spinner from `LoadingScreen` during the load. Renders `DIDAvatar`, identity card, two status indicator cards, and three action buttons. Log out calls `logOut()` and signals parent via `onlogout`. 6 + 7 + **Tech Stack:** Svelte 5, TypeScript, `$lib/ipc.ts` (Phase 1 types + functions) 8 + 9 + **Scope:** Phase 3 of 6 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-150.AC1: Identity card displays correctly 18 + - **MM-150.AC1.1 Success:** Home screen shows the user's handle from `getSession` response 19 + - **MM-150.AC1.2 Success:** DID is displayed truncated as `did:plc:XXXXXXXX…XXXXXX` 20 + - **MM-150.AC1.3 Success:** Copy button copies the full untruncated DID to clipboard 21 + - **MM-150.AC1.4 Success:** Email from `getSession` is shown 22 + - **MM-150.AC1.5 Success:** DID-derived avatar circle is visible with a stable hue 23 + - **MM-150.AC1.6 Success:** Avatar shows the first letter of the handle as its initial 24 + - **MM-150.AC1.7 Edge:** Avatar shows `?` when handle is `handle.invalid` 25 + - **MM-150.AC1.8 Edge:** Loading spinner is shown while `loadHomeData()` is in flight 26 + 27 + ### MM-150.AC2: Status indicators are accurate 28 + - **MM-150.AC2.1 Success:** Relay status shows Connected when `relayHealthy` is true 29 + - **MM-150.AC2.2 Failure:** Relay status shows Error when `relayHealthy` is false 30 + - **MM-150.AC2.3 Success:** Session status shows Active when `session` is non-null 31 + - **MM-150.AC2.4 Failure:** Session status shows Error when `session` is null 32 + - **MM-150.AC2.5 Edge:** Relay and session statuses are independent 33 + 34 + ### MM-150.AC3: Three action flows work 35 + - **MM-150.AC3.1 Success:** Log out clears `oauth-access-token`, `oauth-refresh-token`, and `did` from Keychain 36 + - **MM-150.AC3.2 Success:** Log out navigates to the welcome screen 37 + - **MM-150.AC3.3 Success:** Device key and DPoP key remain in Keychain after logout 38 + - **MM-150.AC3.4 Success:** Tapping View DID Document navigates to `did_document` step 39 + - **MM-150.AC3.8 Edge:** View DID Document button is hidden when `session.didDoc` is null 40 + - **MM-150.AC3.10 Success:** Tapping Recovery Info navigates to `recovery_info` step 41 + 42 + --- 43 + 44 + <!-- START_SUBCOMPONENT_A (tasks 1-1) --> 45 + <!-- START_TASK_1 --> 46 + ### Task 1: Create `HomeScreen.svelte` 47 + 48 + **Verifies:** MM-150.AC1.1–AC1.8, MM-150.AC2.1–AC2.5, MM-150.AC3.1–AC3.4, MM-150.AC3.8, MM-150.AC3.10 49 + 50 + **Files:** 51 + - Create: `apps/identity-wallet/src/lib/components/home/HomeScreen.svelte` 52 + 53 + **DID truncation specification:** 54 + 55 + The `did:plc:` prefix is always shown in full. The method-specific ID (everything after `did:plc:`) is truncated: first 8 chars + `…` + last 6 chars. 56 + 57 + Examples: 58 + - `did:plc:abcdefghijklmnopqrstuvwx` → `did:plc:abcdefgh…uvwx` (only works if ≥ 14 chars after prefix) 59 + - `did:plc:abc` → `did:plc:abc` (too short, shown as-is) 60 + 61 + ``` 62 + displayDid: full DID when method-specific ID < 14 chars 63 + : "did:plc:" + first8 + "…" + last6 otherwise 64 + ``` 65 + 66 + **Implementation:** 67 + 68 + Create `apps/identity-wallet/src/lib/components/home/HomeScreen.svelte`: 69 + 70 + ```svelte 71 + <script lang="ts"> 72 + import { onMount } from 'svelte'; 73 + import { loadHomeData, logOut, type HomeData } from '$lib/ipc'; 74 + import DIDAvatar from './DIDAvatar.svelte'; 75 + 76 + let { 77 + onnavdiddoc, 78 + onnavrecovery, 79 + onlogout, 80 + }: { 81 + onnavdiddoc: (data: HomeData) => void; 82 + onnavrecovery: (data: HomeData) => void; 83 + onlogout: () => void; 84 + } = $props(); 85 + 86 + let homeData = $state<HomeData | null>(null); 87 + let loading = $state(true); 88 + let didCopied = $state(false); 89 + 90 + async function loadData() { 91 + loading = true; 92 + homeData = await loadHomeData(); 93 + loading = false; 94 + } 95 + 96 + onMount(() => { 97 + loadData(); 98 + }); 99 + 100 + // Truncate the DID for display on narrow mobile screens. 101 + // "did:plc:abcdefghijklmnopqrstuvwx" → "did:plc:abcdefgh…uvwxyz" 102 + let displayDid = $derived.by(() => { 103 + const did = homeData?.session?.did ?? ''; 104 + const prefix = 'did:plc:'; 105 + if (!did.startsWith(prefix)) return did; 106 + const specific = did.slice(prefix.length); 107 + if (specific.length < 14) return did; 108 + return `${prefix}${specific.slice(0, 8)}…${specific.slice(-6)}`; 109 + }); 110 + 111 + async function copyDid() { 112 + const did = homeData?.session?.did; 113 + if (!did) return; 114 + try { 115 + await navigator.clipboard.writeText(did); 116 + didCopied = true; 117 + setTimeout(() => { didCopied = false; }, 2000); 118 + } catch (e) { 119 + console.error('clipboard write failed:', e); 120 + } 121 + } 122 + 123 + async function handleLogOut() { 124 + await logOut(); 125 + onlogout(); 126 + } 127 + </script> 128 + 129 + {#if loading} 130 + <div class="screen screen--center"> 131 + <div class="spinner" aria-label="Loading"></div> 132 + <p class="status-text">Loading…</p> 133 + </div> 134 + {:else} 135 + <div class="screen"> 136 + <div class="header"> 137 + <h1 class="title">Identity Wallet</h1> 138 + <button class="refresh-btn" onclick={loadData} aria-label="Refresh">↻</button> 139 + </div> 140 + 141 + {#if homeData?.session} 142 + <!-- Identity card --> 143 + <div class="identity-card"> 144 + <DIDAvatar did={homeData.session.did} handle={homeData.session.handle} /> 145 + <div class="identity-info"> 146 + <p class="handle">@{homeData.session.handle}</p> 147 + <button class="did-btn" onclick={copyDid} title="Tap to copy full DID"> 148 + <span class="did-text">{displayDid}</span> 149 + <span class="copy-hint">{didCopied ? 'Copied!' : 'Copy'}</span> 150 + </button> 151 + <p class="email">{homeData.session.email}</p> 152 + </div> 153 + </div> 154 + {:else} 155 + <div class="identity-card identity-card--empty"> 156 + <p class="empty-text">Session unavailable</p> 157 + {#if homeData?.sessionError} 158 + <p class="error-code">{homeData.sessionError}</p> 159 + {/if} 160 + </div> 161 + {/if} 162 + 163 + <!-- Status indicators --> 164 + <div class="status-section"> 165 + <div class="status-row"> 166 + <span 167 + class="status-dot" 168 + class:status-dot--ok={homeData?.relayHealthy} 169 + class:status-dot--err={!homeData?.relayHealthy} 170 + aria-hidden="true" 171 + ></span> 172 + <div> 173 + <p class="status-label">Relay</p> 174 + <p class="status-value">{homeData?.relayHealthy ? 'Connected' : 'Error'}</p> 175 + </div> 176 + </div> 177 + <div class="status-row"> 178 + <span 179 + class="status-dot" 180 + class:status-dot--ok={homeData?.session != null} 181 + class:status-dot--err={homeData?.session == null} 182 + aria-hidden="true" 183 + ></span> 184 + <div> 185 + <p class="status-label">Session</p> 186 + <p class="status-value">{homeData?.session != null ? 'Active' : 'Error'}</p> 187 + </div> 188 + </div> 189 + </div> 190 + 191 + <!-- Action buttons --> 192 + <div class="actions"> 193 + {#if homeData?.session?.didDoc != null} 194 + <button class="action-btn" onclick={() => onnavdiddoc(homeData!)}> 195 + View DID Document 196 + </button> 197 + {/if} 198 + <button class="action-btn" onclick={() => onnavrecovery(homeData!)}> 199 + Recovery Info 200 + </button> 201 + <button class="action-btn action-btn--danger" onclick={handleLogOut}> 202 + Log Out 203 + </button> 204 + </div> 205 + </div> 206 + {/if} 207 + 208 + <style> 209 + .screen { 210 + display: flex; 211 + flex-direction: column; 212 + height: 100%; 213 + padding: 2rem 1.5rem; 214 + gap: 1.5rem; 215 + overflow-y: auto; 216 + } 217 + 218 + .screen--center { 219 + align-items: center; 220 + justify-content: center; 221 + gap: 1rem; 222 + } 223 + 224 + .spinner { 225 + width: 48px; 226 + height: 48px; 227 + border: 4px solid #e5e7eb; 228 + border-top-color: #007aff; 229 + border-radius: 50%; 230 + animation: spin 0.8s linear infinite; 231 + } 232 + 233 + @keyframes spin { 234 + to { transform: rotate(360deg); } 235 + } 236 + 237 + .status-text { 238 + font-size: 1rem; 239 + color: #6b7280; 240 + margin: 0; 241 + } 242 + 243 + .header { 244 + display: flex; 245 + align-items: center; 246 + justify-content: space-between; 247 + } 248 + 249 + .title { 250 + font-size: 1.4rem; 251 + font-weight: 700; 252 + margin: 0; 253 + color: #111827; 254 + } 255 + 256 + .refresh-btn { 257 + background: none; 258 + border: none; 259 + font-size: 1.4rem; 260 + color: #007aff; 261 + cursor: pointer; 262 + padding: 0.25rem; 263 + line-height: 1; 264 + } 265 + 266 + .identity-card { 267 + background: #f9fafb; 268 + border: 1px solid #d1d5db; 269 + border-radius: 12px; 270 + padding: 1.25rem; 271 + display: flex; 272 + align-items: center; 273 + gap: 1rem; 274 + } 275 + 276 + .identity-card--empty { 277 + flex-direction: column; 278 + align-items: flex-start; 279 + } 280 + 281 + .identity-info { 282 + display: flex; 283 + flex-direction: column; 284 + gap: 0.25rem; 285 + min-width: 0; 286 + } 287 + 288 + .handle { 289 + font-size: 1.1rem; 290 + font-weight: 600; 291 + color: #111827; 292 + margin: 0; 293 + white-space: nowrap; 294 + overflow: hidden; 295 + text-overflow: ellipsis; 296 + } 297 + 298 + .did-btn { 299 + background: none; 300 + border: none; 301 + padding: 0; 302 + cursor: pointer; 303 + display: flex; 304 + align-items: center; 305 + gap: 0.5rem; 306 + text-align: left; 307 + } 308 + 309 + .did-text { 310 + font-family: monospace; 311 + font-size: 0.8rem; 312 + color: #374151; 313 + } 314 + 315 + .copy-hint { 316 + font-size: 0.7rem; 317 + color: #007aff; 318 + white-space: nowrap; 319 + } 320 + 321 + .email { 322 + font-size: 0.85rem; 323 + color: #6b7280; 324 + margin: 0; 325 + white-space: nowrap; 326 + overflow: hidden; 327 + text-overflow: ellipsis; 328 + } 329 + 330 + .empty-text { 331 + font-size: 0.95rem; 332 + color: #6b7280; 333 + margin: 0; 334 + } 335 + 336 + .error-code { 337 + font-family: monospace; 338 + font-size: 0.8rem; 339 + color: #ef4444; 340 + margin: 0; 341 + } 342 + 343 + .status-section { 344 + background: #f9fafb; 345 + border: 1px solid #d1d5db; 346 + border-radius: 12px; 347 + padding: 1rem 1.25rem; 348 + display: flex; 349 + flex-direction: column; 350 + gap: 0.75rem; 351 + } 352 + 353 + .status-row { 354 + display: flex; 355 + align-items: center; 356 + gap: 0.75rem; 357 + } 358 + 359 + .status-dot { 360 + width: 10px; 361 + height: 10px; 362 + border-radius: 50%; 363 + flex-shrink: 0; 364 + } 365 + 366 + .status-dot--ok { 367 + background: #22c55e; 368 + } 369 + 370 + .status-dot--err { 371 + background: #ef4444; 372 + } 373 + 374 + .status-label { 375 + font-size: 0.75rem; 376 + font-weight: 600; 377 + color: #6b7280; 378 + margin: 0; 379 + text-transform: uppercase; 380 + letter-spacing: 0.04em; 381 + } 382 + 383 + .status-value { 384 + font-size: 0.875rem; 385 + color: #111827; 386 + margin: 0; 387 + } 388 + 389 + .actions { 390 + display: flex; 391 + flex-direction: column; 392 + gap: 0.75rem; 393 + margin-top: auto; 394 + } 395 + 396 + .action-btn { 397 + width: 100%; 398 + padding: 0.9rem; 399 + background: #007aff; 400 + color: #fff; 401 + border: none; 402 + border-radius: 12px; 403 + font-size: 1rem; 404 + font-weight: 600; 405 + cursor: pointer; 406 + } 407 + 408 + .action-btn--danger { 409 + background: #f3f4f6; 410 + color: #ef4444; 411 + } 412 + </style> 413 + ``` 414 + 415 + **Verification:** 416 + Run from `apps/identity-wallet/`: `pnpm check` 417 + Expected: No TypeScript errors 418 + 419 + **Commit:** 420 + ```bash 421 + git add apps/identity-wallet/src/lib/components/home/HomeScreen.svelte 422 + git commit -m "feat: add HomeScreen component with identity card and status indicators" 423 + ``` 424 + <!-- END_TASK_1 --> 425 + <!-- END_SUBCOMPONENT_A -->
+310
docs/implementation-plans/2026-03-27-MM-150/phase_04.md
··· 1 + # MM-150 Implementation Plan — Phase 4: State machine wiring 2 + 3 + **Goal:** Connect all three home screens into the `+page.svelte` flat state machine. 4 + 5 + **Architecture:** Extend the existing `OnboardingStep` discriminated union with `home`, `did_document`, and `recovery_info`. Rename the `authenticated` stub to `home`. Add page-level `homeData` state so sub-screens receive already-loaded data without re-fetching. Wire `HomeScreen`, `DIDDocumentScreen`, and `RecoveryInfoScreen` with props and back-navigation callbacks. 6 + 7 + **Tech Stack:** Svelte 5, TypeScript 8 + 9 + **Scope:** Phase 4 of 6 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-150.AC3: Three action flows work 18 + - **MM-150.AC3.4 Success:** Tapping View DID Document navigates to `did_document` step 19 + - **MM-150.AC3.9 Success:** Back from DID document returns to home 20 + - **MM-150.AC3.10 Success:** Tapping Recovery Info navigates to `recovery_info` step 21 + - **MM-150.AC3.14 Success:** Back from recovery info returns to home 22 + 23 + ### MM-150.AC5: App launches to home when already onboarded 24 + - **MM-150.AC5.1 Success:** App starts at the `home` step when OAuth tokens exist in Keychain on launch 25 + - **MM-150.AC5.2 Success:** `homeData` is loaded on mount of `HomeScreen` regardless of entry path 26 + 27 + --- 28 + 29 + <!-- START_SUBCOMPONENT_A (tasks 0-1) --> 30 + <!-- START_TASK_0 --> 31 + ### Task 0: Create stub components for `DIDDocumentScreen` and `RecoveryInfoScreen` 32 + 33 + **Verifies:** (prerequisite — allows Phase 4 imports to resolve before Phases 5 and 6 are executed) 34 + 35 + **Files:** 36 + - Create: `apps/identity-wallet/src/lib/components/home/DIDDocumentScreen.svelte` (stub) 37 + - Create: `apps/identity-wallet/src/lib/components/home/RecoveryInfoScreen.svelte` (stub) 38 + 39 + **Why:** Phase 4 adds imports for `DIDDocumentScreen` and `RecoveryInfoScreen` to `+page.svelte`. These imports will cause TypeScript errors until the component files exist. Phases 5 and 6 will replace these stubs with full implementations. 40 + 41 + Create `apps/identity-wallet/src/lib/components/home/DIDDocumentScreen.svelte`: 42 + 43 + ```svelte 44 + <script lang="ts"> 45 + let { 46 + didDoc, 47 + onback, 48 + }: { 49 + didDoc: Record<string, unknown>; 50 + onback: () => void; 51 + } = $props(); 52 + </script> 53 + <div>DIDDocumentScreen stub — replaced by Phase 5</div> 54 + ``` 55 + 56 + Create `apps/identity-wallet/src/lib/components/home/RecoveryInfoScreen.svelte`: 57 + 58 + ```svelte 59 + <script lang="ts"> 60 + let { 61 + share1InKeychain, 62 + onback, 63 + }: { 64 + share1InKeychain: boolean; 65 + onback: () => void; 66 + } = $props(); 67 + </script> 68 + <div>RecoveryInfoScreen stub — replaced by Phase 6</div> 69 + ``` 70 + 71 + **Note:** Phase 5 overwrites `DIDDocumentScreen.svelte` with the full implementation. Phase 6 overwrites `RecoveryInfoScreen.svelte`. These stubs exist only to allow `pnpm check` to pass during Phase 4. 72 + 73 + **Verification:** 74 + Run from `apps/identity-wallet/`: `pnpm check` 75 + Expected: No TypeScript errors from the stub components themselves 76 + 77 + **Commit:** (defer — commit alongside the `+page.svelte` changes in Task 1) 78 + <!-- END_TASK_0 --> 79 + 80 + <!-- START_TASK_1 --> 81 + ### Task 1: Extend OnboardingStep union and wire home screens in `+page.svelte` 82 + 83 + **Verifies:** MM-150.AC3.4, MM-150.AC3.9, MM-150.AC3.10, MM-150.AC3.14, MM-150.AC5.1, MM-150.AC5.2 84 + 85 + **Files:** 86 + - Modify: `apps/identity-wallet/src/routes/+page.svelte` 87 + 88 + **Current state of `+page.svelte` (verified 2026-03-27):** 89 + 90 + - `OnboardingStep` union defined at lines 26–39; currently ends with `'authenticated'` and `'auth_failed'` 91 + - The `auth_ready` event listener at line 66 calls `goTo('authenticated')` 92 + - The `authenticated` stub at lines 207–212 is a simple `<div>` placeholder 93 + - There is no `home`, `did_document`, or `recovery_info` step 94 + 95 + **Changes required (in order):** 96 + 97 + **1. Add new imports at the top of the `<script>` block** 98 + 99 + After the existing Svelte/IPC imports (after line 14), add: 100 + 101 + ```typescript 102 + import HomeScreen from '$lib/components/home/HomeScreen.svelte'; 103 + import DIDDocumentScreen from '$lib/components/home/DIDDocumentScreen.svelte'; 104 + import RecoveryInfoScreen from '$lib/components/home/RecoveryInfoScreen.svelte'; 105 + import { type HomeData } from '$lib/ipc'; 106 + ``` 107 + 108 + **2. Replace `OnboardingStep` union definition** 109 + 110 + Replace the current union (lines 26–39): 111 + 112 + ```typescript 113 + type OnboardingStep = 114 + | 'welcome' 115 + | 'claim_code' 116 + | 'email' 117 + | 'handle' 118 + | 'password' 119 + | 'loading' 120 + | 'did_ceremony' 121 + | 'did_success' 122 + | 'shamir_backup' 123 + | 'complete' 124 + | 'authenticating' 125 + | 'authenticated' 126 + | 'auth_failed'; 127 + ``` 128 + 129 + With: 130 + 131 + ```typescript 132 + type OnboardingStep = 133 + | 'welcome' 134 + | 'claim_code' 135 + | 'email' 136 + | 'handle' 137 + | 'password' 138 + | 'loading' 139 + | 'did_ceremony' 140 + | 'did_success' 141 + | 'shamir_backup' 142 + | 'complete' 143 + | 'authenticating' 144 + | 'home' 145 + | 'did_document' 146 + | 'recovery_info' 147 + | 'auth_failed'; 148 + ``` 149 + 150 + **3. Add `homeData` state variable** 151 + 152 + After the `let authError` state declaration (after line 54), add: 153 + 154 + ```typescript 155 + let homeData = $state<HomeData | null>(null); 156 + ``` 157 + 158 + **4. Update auth_ready listener** 159 + 160 + In `onMount` (line 66), change `goTo('authenticated')` to `goTo('home')`: 161 + 162 + ```typescript 163 + listen('auth_ready', () => { 164 + goTo('home'); 165 + }); 166 + ``` 167 + 168 + **5. Replace the `authenticated` stub block with the three home screens** 169 + 170 + Replace the current `{:else if step === 'authenticated'}` block (lines 207–212): 171 + 172 + ```svelte 173 + {:else if step === 'authenticated'} 174 + <div class="oauth-screen"> 175 + <div class="oauth-icon" aria-hidden="true">✓</div> 176 + <h2 class="oauth-title">Authenticated</h2> 177 + <p class="oauth-body">Your identity wallet is ready.</p> 178 + </div> 179 + ``` 180 + 181 + With three new blocks (place them in the same position in the `{#if}` chain): 182 + 183 + ```svelte 184 + {:else if step === 'home'} 185 + <HomeScreen 186 + onnavdiddoc={() => goTo('did_document')} 187 + onnavrecovery={() => goTo('recovery_info')} 188 + onlogout={() => goTo('welcome')} 189 + /> 190 + 191 + {:else if step === 'did_document'} 192 + <DIDDocumentScreen 193 + didDoc={homeData?.session?.didDoc ?? {}} 194 + onback={() => goTo('home')} 195 + /> 196 + 197 + {:else if step === 'recovery_info'} 198 + <RecoveryInfoScreen 199 + share1InKeychain={homeData?.share1InKeychain ?? false} 200 + onback={() => goTo('home')} 201 + /> 202 + ``` 203 + 204 + **6. Update the `AuthenticatingScreen` `onresolved` callback** 205 + 206 + The `AuthenticatingScreen` component (within `{:else if step === 'authenticating'}`) uses `onresolved={() => goTo('authenticated')}`. Since `'authenticated'` has been removed from `OnboardingStep`, this must also be updated. 207 + 208 + Find: 209 + ```svelte 210 + {:else if step === 'authenticating'} 211 + <AuthenticatingScreen onresolved={() => goTo('authenticated')} /> 212 + ``` 213 + 214 + Change to: 215 + ```svelte 216 + {:else if step === 'authenticating'} 217 + <AuthenticatingScreen onresolved={() => goTo('home')} /> 218 + ``` 219 + 220 + --- 221 + 222 + **Note on `homeData` prop passing:** `HomeScreen` loads its own data via `loadHomeData()` on mount and stores it internally. However, `DIDDocumentScreen` and `RecoveryInfoScreen` receive data as props from the parent. The HomeScreen must emit the loaded data back to the parent so these sub-screens can receive it. 223 + 224 + To achieve this, update the `onnavdiddoc` and `onnavrecovery` callbacks in `HomeScreen` to accept the loaded `HomeData` and store it in page-level state. Modify the HomeScreen `$props()` to pass `homeData` up: 225 + 226 + The HomeScreen should emit `homeData` to the parent when navigating to sub-screens. Update the `HomeScreen.svelte` props definition (see Phase 3) to pass `homeData` back via the nav callbacks: 227 + 228 + Update `HomeScreen.svelte`'s props to: 229 + 230 + ```typescript 231 + let { 232 + onnavdiddoc, 233 + onnavrecovery, 234 + onlogout, 235 + }: { 236 + onnavdiddoc: (data: HomeData) => void; 237 + onnavrecovery: (data: HomeData) => void; 238 + onlogout: () => void; 239 + } = $props(); 240 + ``` 241 + 242 + And update the nav button handlers in `HomeScreen.svelte`: 243 + 244 + ```svelte 245 + <button class="action-btn" onclick={() => onnavdiddoc(homeData!)}> 246 + View DID Document 247 + </button> 248 + ... 249 + <button class="action-btn" onclick={() => onnavrecovery(homeData!)}> 250 + Recovery Info 251 + </button> 252 + ``` 253 + 254 + Then in `+page.svelte`, update the three home screen blocks: 255 + 256 + ```svelte 257 + {:else if step === 'home'} 258 + <HomeScreen 259 + onnavdiddoc={(data) => { homeData = data; goTo('did_document'); }} 260 + onnavrecovery={(data) => { homeData = data; goTo('recovery_info'); }} 261 + onlogout={() => goTo('welcome')} 262 + /> 263 + 264 + {:else if step === 'did_document'} 265 + <DIDDocumentScreen 266 + didDoc={homeData?.session?.didDoc ?? {}} 267 + onback={() => goTo('home')} 268 + /> 269 + 270 + {:else if step === 'recovery_info'} 271 + <RecoveryInfoScreen 272 + share1InKeychain={homeData?.share1InKeychain ?? false} 273 + onback={() => goTo('home')} 274 + /> 275 + ``` 276 + 277 + **Summary of all edits to `+page.svelte`:** 278 + 1. Add 4 import lines after existing imports 279 + 2. Extend `OnboardingStep` union (add `'home'`, `'did_document'`, `'recovery_info'`; remove `'authenticated'`) 280 + 3. Add `let homeData = $state<HomeData | null>(null);` after `authError` state 281 + 4. Change `goTo('authenticated')` → `goTo('home')` in auth_ready listener 282 + 5. Replace `{:else if step === 'authenticated'} ... {:else if step === 'auth_failed'}` block with three new step blocks (`home`, `did_document`, `recovery_info`) followed by the existing `auth_failed` block 283 + 6. Change `AuthenticatingScreen onresolved={() => goTo('authenticated')}` → `onresolved={() => goTo('home')}` 284 + 285 + **Also update `HomeScreen.svelte` (from Phase 3):** 286 + - Change `onnavdiddoc: () => void` → `onnavdiddoc: (data: HomeData) => void` 287 + - Change `onnavrecovery: () => void` → `onnavrecovery: (data: HomeData) => void` 288 + - Add `import { loadHomeData, logOut, type HomeData } from '$lib/ipc';` (it likely already imports these — confirm) 289 + - Update button onclick handlers to pass `homeData!` 290 + 291 + **Verification:** 292 + Run from `apps/identity-wallet/`: `pnpm check` 293 + Expected: No TypeScript errors 294 + 295 + Run: `cargo tauri ios dev` (requires Xcode + iOS Simulator) 296 + Expected: 297 + - App starts at welcome screen (no tokens) 298 + - After full onboarding + OAuth flow, app navigates to home screen showing identity card 299 + - `auth_ready` event (simulated by relaunching with tokens in Keychain) navigates to home screen 300 + - "View DID Document" button only appears when `homeData.session.didDoc` is non-null 301 + - Back buttons return to home 302 + 303 + **Commit:** 304 + ```bash 305 + git add apps/identity-wallet/src/routes/+page.svelte \ 306 + apps/identity-wallet/src/lib/components/home/HomeScreen.svelte 307 + git commit -m "feat: wire home, did_document, and recovery_info steps into OnboardingStep state machine" 308 + ``` 309 + <!-- END_TASK_1 --> 310 + <!-- END_SUBCOMPONENT_A -->
+366
docs/implementation-plans/2026-03-27-MM-150/phase_05.md
··· 1 + # MM-150 Implementation Plan — Phase 5: DIDDocumentScreen component 2 + 3 + **Goal:** Structured DID document viewer with raw JSON fallback. 4 + 5 + **Architecture:** Svelte 5 component. Accepts `didDoc: Record<string, unknown>` (the full DID document object from `session.didDoc`) and `onback` callback. Renders structured sections for `id`, `alsoKnownAs`, `verificationMethod` (with copy button for `publicKeyMultibase`), and `service`. A toggle reveals the full document as a monospace `<pre>` block. 6 + 7 + **Tech Stack:** Svelte 5, TypeScript 8 + 9 + **Scope:** Phase 5 of 6 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-150.AC3: Three action flows work 18 + - **MM-150.AC3.5 Success:** DID document view shows `id`, `alsoKnownAs`, `verificationMethod`, and `service` fields in structured form 19 + - **MM-150.AC3.6 Success:** Raw JSON toggle reveals the full DID document as a monospace block 20 + - **MM-150.AC3.7 Success:** Key copy button copies `publicKeyMultibase` value to clipboard 21 + - **MM-150.AC3.9 Success:** Back from DID document returns to home 22 + 23 + --- 24 + 25 + <!-- START_SUBCOMPONENT_A (tasks 1-1) --> 26 + <!-- START_TASK_1 --> 27 + ### Task 1: Create `DIDDocumentScreen.svelte` 28 + 29 + **Verifies:** MM-150.AC3.5, MM-150.AC3.6, MM-150.AC3.7, MM-150.AC3.9 30 + 31 + **Files:** 32 + - Create: `apps/identity-wallet/src/lib/components/home/DIDDocumentScreen.svelte` 33 + 34 + **DID document shape (from ATProto spec, verified in Phase 1C):** 35 + 36 + ```typescript 37 + // didDoc fields that may be present: 38 + { 39 + id: string, // the DID itself 40 + alsoKnownAs?: string[], // at://handle URIs 41 + verificationMethod?: Array<{ 42 + id: string, 43 + type: string, // "Multikey" or legacy type 44 + controller: string, 45 + publicKeyMultibase: string // the value the copy button copies 46 + }>, 47 + service?: Array<{ 48 + id: string, 49 + type: string, // "AtprotoPersonalDataServer" 50 + serviceEndpoint: string // HTTPS URL 51 + }> 52 + } 53 + ``` 54 + 55 + All fields except `id` may be absent. The component must render gracefully when arrays are empty or absent. 56 + 57 + **Implementation:** 58 + 59 + Create `apps/identity-wallet/src/lib/components/home/DIDDocumentScreen.svelte`: 60 + 61 + ```svelte 62 + <script lang="ts"> 63 + let { 64 + didDoc, 65 + onback, 66 + }: { 67 + didDoc: Record<string, unknown>; 68 + onback: () => void; 69 + } = $props(); 70 + 71 + let showRaw = $state(false); 72 + let copiedKeyId = $state<string | null>(null); 73 + 74 + // Extract typed arrays from the loosely-typed didDoc. 75 + let verificationMethods = $derived( 76 + Array.isArray(didDoc.verificationMethod) 77 + ? (didDoc.verificationMethod as Array<Record<string, unknown>>) 78 + : [] 79 + ); 80 + 81 + let alsoKnownAs = $derived( 82 + Array.isArray(didDoc.alsoKnownAs) 83 + ? (didDoc.alsoKnownAs as Array<string>) 84 + : [] 85 + ); 86 + 87 + let services = $derived( 88 + Array.isArray(didDoc.service) 89 + ? (didDoc.service as Array<Record<string, unknown>>) 90 + : [] 91 + ); 92 + 93 + let rawJson = $derived(JSON.stringify(didDoc, null, 2)); 94 + 95 + async function copyKey(keyId: string, value: string) { 96 + try { 97 + await navigator.clipboard.writeText(value); 98 + copiedKeyId = keyId; 99 + setTimeout(() => { copiedKeyId = null; }, 2000); 100 + } catch (e) { 101 + console.error('clipboard write failed:', e); 102 + } 103 + } 104 + </script> 105 + 106 + <div class="screen"> 107 + <div class="header"> 108 + <button class="back-btn" onclick={onback} aria-label="Back">‹ Back</button> 109 + <h2 class="title">DID Document</h2> 110 + </div> 111 + 112 + <!-- Identity section --> 113 + <div class="section"> 114 + <p class="section-label">Identifier</p> 115 + <p class="mono-value">{didDoc.id ?? '—'}</p> 116 + </div> 117 + 118 + <!-- alsoKnownAs --> 119 + {#if alsoKnownAs.length > 0} 120 + <div class="section"> 121 + <p class="section-label">Also Known As</p> 122 + {#each alsoKnownAs as alias} 123 + <p class="mono-value">{alias}</p> 124 + {/each} 125 + </div> 126 + {/if} 127 + 128 + <!-- Verification Methods --> 129 + {#if verificationMethods.length > 0} 130 + <div class="section"> 131 + <p class="section-label">Verification Keys</p> 132 + {#each verificationMethods as method} 133 + <div class="key-card"> 134 + <p class="key-type">{method.type ?? 'Unknown'}</p> 135 + <p class="key-id">{method.id}</p> 136 + {#if method.publicKeyMultibase} 137 + <div class="key-value-row"> 138 + <code class="key-value">{String(method.publicKeyMultibase).slice(0, 20)}…</code> 139 + <button 140 + class="copy-btn" 141 + onclick={() => copyKey(String(method.id), String(method.publicKeyMultibase))} 142 + > 143 + {copiedKeyId === String(method.id) ? 'Copied!' : 'Copy'} 144 + </button> 145 + </div> 146 + {/if} 147 + </div> 148 + {/each} 149 + </div> 150 + {/if} 151 + 152 + <!-- Services --> 153 + {#if services.length > 0} 154 + <div class="section"> 155 + <p class="section-label">Services</p> 156 + {#each services as svc} 157 + <div class="service-card"> 158 + <p class="service-type">{svc.type ?? 'Unknown'}</p> 159 + <p class="service-endpoint">{svc.serviceEndpoint}</p> 160 + </div> 161 + {/each} 162 + </div> 163 + {/if} 164 + 165 + <!-- Raw JSON toggle --> 166 + <button 167 + class="toggle-btn" 168 + onclick={() => { showRaw = !showRaw; }} 169 + > 170 + {showRaw ? 'Hide Raw JSON' : 'Show Raw JSON'} 171 + </button> 172 + 173 + {#if showRaw} 174 + <pre class="raw-json">{rawJson}</pre> 175 + {/if} 176 + </div> 177 + 178 + <style> 179 + .screen { 180 + display: flex; 181 + flex-direction: column; 182 + height: 100%; 183 + padding: 2rem 1.5rem; 184 + gap: 1.25rem; 185 + overflow-y: auto; 186 + } 187 + 188 + .header { 189 + display: flex; 190 + align-items: center; 191 + gap: 0.75rem; 192 + } 193 + 194 + .back-btn { 195 + background: none; 196 + border: none; 197 + font-size: 1rem; 198 + color: #007aff; 199 + cursor: pointer; 200 + padding: 0; 201 + font-weight: 500; 202 + white-space: nowrap; 203 + } 204 + 205 + .title { 206 + font-size: 1.2rem; 207 + font-weight: 700; 208 + color: #111827; 209 + margin: 0; 210 + } 211 + 212 + .section { 213 + background: #f9fafb; 214 + border: 1px solid #d1d5db; 215 + border-radius: 12px; 216 + padding: 1rem 1.25rem; 217 + display: flex; 218 + flex-direction: column; 219 + gap: 0.5rem; 220 + } 221 + 222 + .section-label { 223 + font-size: 0.75rem; 224 + font-weight: 600; 225 + color: #6b7280; 226 + margin: 0; 227 + text-transform: uppercase; 228 + letter-spacing: 0.05em; 229 + } 230 + 231 + .mono-value { 232 + font-family: monospace; 233 + font-size: 0.8rem; 234 + color: #374151; 235 + margin: 0; 236 + word-break: break-all; 237 + } 238 + 239 + .key-card { 240 + background: #fff; 241 + border: 1px solid #e5e7eb; 242 + border-radius: 8px; 243 + padding: 0.75rem; 244 + display: flex; 245 + flex-direction: column; 246 + gap: 0.25rem; 247 + } 248 + 249 + .key-type { 250 + font-size: 0.8rem; 251 + font-weight: 600; 252 + color: #374151; 253 + margin: 0; 254 + } 255 + 256 + .key-id { 257 + font-family: monospace; 258 + font-size: 0.75rem; 259 + color: #6b7280; 260 + margin: 0; 261 + word-break: break-all; 262 + } 263 + 264 + .key-value-row { 265 + display: flex; 266 + align-items: center; 267 + gap: 0.5rem; 268 + margin-top: 0.25rem; 269 + } 270 + 271 + .key-value { 272 + font-family: monospace; 273 + font-size: 0.75rem; 274 + color: #374151; 275 + background: #f3f4f6; 276 + padding: 0.2rem 0.4rem; 277 + border-radius: 4px; 278 + flex: 1; 279 + min-width: 0; 280 + overflow: hidden; 281 + text-overflow: ellipsis; 282 + white-space: nowrap; 283 + } 284 + 285 + .copy-btn { 286 + background: #007aff; 287 + color: #fff; 288 + border: none; 289 + border-radius: 6px; 290 + padding: 0.3rem 0.75rem; 291 + font-size: 0.8rem; 292 + font-weight: 600; 293 + cursor: pointer; 294 + white-space: nowrap; 295 + flex-shrink: 0; 296 + } 297 + 298 + .service-card { 299 + background: #fff; 300 + border: 1px solid #e5e7eb; 301 + border-radius: 8px; 302 + padding: 0.75rem; 303 + display: flex; 304 + flex-direction: column; 305 + gap: 0.25rem; 306 + } 307 + 308 + .service-type { 309 + font-size: 0.8rem; 310 + font-weight: 600; 311 + color: #374151; 312 + margin: 0; 313 + } 314 + 315 + .service-endpoint { 316 + font-family: monospace; 317 + font-size: 0.8rem; 318 + color: #6b7280; 319 + margin: 0; 320 + word-break: break-all; 321 + } 322 + 323 + .toggle-btn { 324 + background: none; 325 + border: 1px solid #d1d5db; 326 + border-radius: 8px; 327 + padding: 0.6rem 1rem; 328 + font-size: 0.9rem; 329 + color: #374151; 330 + cursor: pointer; 331 + text-align: center; 332 + } 333 + 334 + .raw-json { 335 + background: #f3f4f6; 336 + border: 1px solid #d1d5db; 337 + border-radius: 8px; 338 + padding: 1rem; 339 + font-family: monospace; 340 + font-size: 0.75rem; 341 + color: #374151; 342 + overflow-x: auto; 343 + white-space: pre; 344 + word-break: normal; 345 + margin: 0; 346 + } 347 + </style> 348 + ``` 349 + 350 + **Verification:** 351 + Run from `apps/identity-wallet/`: `pnpm check` 352 + Expected: No TypeScript errors 353 + 354 + Run `cargo tauri ios dev` and navigate to a DID document screen: 355 + - Structured sections render for all present fields 356 + - Raw JSON toggle shows/hides a monospace `<pre>` block 357 + - Copy button for `publicKeyMultibase` shows "Copied!" for 2 seconds 358 + - Back button returns to home 359 + 360 + **Commit:** 361 + ```bash 362 + git add apps/identity-wallet/src/lib/components/home/DIDDocumentScreen.svelte 363 + git commit -m "feat: add DIDDocumentScreen component with structured view and raw JSON toggle" 364 + ``` 365 + <!-- END_TASK_1 --> 366 + <!-- END_SUBCOMPONENT_A -->
+250
docs/implementation-plans/2026-03-27-MM-150/phase_06.md
··· 1 + # MM-150 Implementation Plan — Phase 6: RecoveryInfoScreen component 2 + 3 + **Goal:** Read-only display of Shamir recovery share status. 4 + 5 + **Architecture:** Svelte 5 component. Accepts `share1InKeychain: boolean` (live Keychain result from `HomeData`) and `onback` callback. Shows three share rows: Share 1 reflects the live Keychain check, Share 2 is a static relay custody fact from onboarding, Share 3 is a reminder that the user holds it. 6 + 7 + **Tech Stack:** Svelte 5, TypeScript 8 + 9 + **Scope:** Phase 6 of 6 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-150.AC3: Three action flows work 18 + - **MM-150.AC3.11 Success:** Share 1 shows ✓ when `recovery-share-1` exists in Keychain 19 + - **MM-150.AC3.12 Failure:** Share 1 shows ✗ when `recovery-share-1` is absent from Keychain 20 + - **MM-150.AC3.13 Success:** Share 2 always shows ✓ (static relay custody fact from onboarding) 21 + - **MM-150.AC3.14 Success:** Back from recovery info returns to home 22 + 23 + --- 24 + 25 + <!-- START_SUBCOMPONENT_A (tasks 1-1) --> 26 + <!-- START_TASK_1 --> 27 + ### Task 1: Create `RecoveryInfoScreen.svelte` 28 + 29 + **Verifies:** MM-150.AC3.11, MM-150.AC3.12, MM-150.AC3.13, MM-150.AC3.14 30 + 31 + **Files:** 32 + - Create: `apps/identity-wallet/src/lib/components/home/RecoveryInfoScreen.svelte` 33 + 34 + **Keychain account name for Share 1:** `"recovery-share-1"` (defined at `apps/identity-wallet/src-tauri/src/lib.rs:388` and documented in CLAUDE.md invariants). The Rust `load_home_data` command already checks this key and returns `share1InKeychain: boolean` in HomeData. This component simply displays the result — no IPC calls. 35 + 36 + **Share 3 status:** Share 3 is always shown as a manual backup reminder. It is the share returned to the user via `DIDCeremonyResult.share3` during onboarding and displayed in `ShamirBackupScreen`. This screen reminds the user that they are responsible for it. 37 + 38 + **Implementation:** 39 + 40 + Create `apps/identity-wallet/src/lib/components/home/RecoveryInfoScreen.svelte`: 41 + 42 + ```svelte 43 + <script lang="ts"> 44 + let { 45 + share1InKeychain, 46 + onback, 47 + }: { 48 + share1InKeychain: boolean; 49 + onback: () => void; 50 + } = $props(); 51 + </script> 52 + 53 + <div class="screen"> 54 + <div class="header"> 55 + <button class="back-btn" onclick={onback} aria-label="Back">‹ Back</button> 56 + <h2 class="title">Recovery Info</h2> 57 + </div> 58 + 59 + <p class="description"> 60 + Your identity can be recovered with any 2 of 3 recovery shares. 61 + </p> 62 + 63 + <!-- Share 1 --> 64 + <div class="share-row" class:share-row--ok={share1InKeychain} class:share-row--err={!share1InKeychain}> 65 + <div class="share-icon" class:share-icon--ok={share1InKeychain} class:share-icon--err={!share1InKeychain} aria-hidden="true"> 66 + {share1InKeychain ? '✓' : '✗'} 67 + </div> 68 + <div class="share-info"> 69 + <p class="share-label">Share 1 of 3</p> 70 + <p class="share-desc"> 71 + {share1InKeychain 72 + ? 'Saved to iCloud Keychain — backed up automatically' 73 + : 'Not found in Keychain — this device may have lost it'} 74 + </p> 75 + </div> 76 + </div> 77 + 78 + <!-- Share 2 --> 79 + <div class="share-row share-row--ok"> 80 + <div class="share-icon share-icon--ok" aria-hidden="true">✓</div> 81 + <div class="share-info"> 82 + <p class="share-label">Share 2 of 3</p> 83 + <p class="share-desc">Held by the relay — stored during account setup</p> 84 + </div> 85 + </div> 86 + 87 + <!-- Share 3 --> 88 + <div class="share-row share-row--neutral"> 89 + <div class="share-icon share-icon--neutral" aria-hidden="true">📋</div> 90 + <div class="share-info"> 91 + <p class="share-label">Share 3 of 3</p> 92 + <p class="share-desc">Your manual backup — shown during setup. Keep it safe.</p> 93 + </div> 94 + </div> 95 + 96 + <div class="note"> 97 + <p>Any 2 shares together can restore your identity. Keep Share 3 somewhere safe and offline.</p> 98 + </div> 99 + </div> 100 + 101 + <style> 102 + .screen { 103 + display: flex; 104 + flex-direction: column; 105 + height: 100%; 106 + padding: 2rem 1.5rem; 107 + gap: 1.25rem; 108 + overflow-y: auto; 109 + } 110 + 111 + .header { 112 + display: flex; 113 + align-items: center; 114 + gap: 0.75rem; 115 + } 116 + 117 + .back-btn { 118 + background: none; 119 + border: none; 120 + font-size: 1rem; 121 + color: #007aff; 122 + cursor: pointer; 123 + padding: 0; 124 + font-weight: 500; 125 + white-space: nowrap; 126 + } 127 + 128 + .title { 129 + font-size: 1.2rem; 130 + font-weight: 700; 131 + color: #111827; 132 + margin: 0; 133 + } 134 + 135 + .description { 136 + font-size: 0.9rem; 137 + color: #6b7280; 138 + margin: 0; 139 + line-height: 1.5; 140 + } 141 + 142 + .share-row { 143 + display: flex; 144 + align-items: flex-start; 145 + gap: 0.75rem; 146 + padding: 1rem 1.25rem; 147 + border-radius: 12px; 148 + border: 1px solid transparent; 149 + } 150 + 151 + .share-row--ok { 152 + background: #f0fdf4; 153 + border-color: #bbf7d0; 154 + } 155 + 156 + .share-row--err { 157 + background: #fef2f2; 158 + border-color: #fecaca; 159 + } 160 + 161 + .share-row--neutral { 162 + background: #f9fafb; 163 + border-color: #d1d5db; 164 + } 165 + 166 + .share-icon { 167 + width: 36px; 168 + height: 36px; 169 + border-radius: 50%; 170 + display: flex; 171 + align-items: center; 172 + justify-content: center; 173 + font-size: 1rem; 174 + font-weight: 700; 175 + flex-shrink: 0; 176 + } 177 + 178 + .share-icon--ok { 179 + background: #22c55e; 180 + color: #fff; 181 + } 182 + 183 + .share-icon--err { 184 + background: #ef4444; 185 + color: #fff; 186 + } 187 + 188 + .share-icon--neutral { 189 + background: #e5e7eb; 190 + color: #374151; 191 + font-size: 1.1rem; 192 + } 193 + 194 + .share-info { 195 + display: flex; 196 + flex-direction: column; 197 + gap: 0.2rem; 198 + } 199 + 200 + .share-label { 201 + font-size: 0.8rem; 202 + font-weight: 600; 203 + color: #374151; 204 + margin: 0; 205 + text-transform: uppercase; 206 + letter-spacing: 0.04em; 207 + } 208 + 209 + .share-desc { 210 + font-size: 0.875rem; 211 + color: #6b7280; 212 + margin: 0; 213 + line-height: 1.4; 214 + } 215 + 216 + .note { 217 + background: #f9fafb; 218 + border: 1px solid #d1d5db; 219 + border-radius: 12px; 220 + padding: 1rem 1.25rem; 221 + margin-top: auto; 222 + } 223 + 224 + .note p { 225 + font-size: 0.85rem; 226 + color: #6b7280; 227 + margin: 0; 228 + line-height: 1.5; 229 + } 230 + </style> 231 + ``` 232 + 233 + **Verification:** 234 + Run from `apps/identity-wallet/`: `pnpm check` 235 + Expected: No TypeScript errors 236 + 237 + Run `cargo tauri ios dev` and navigate to Recovery Info: 238 + - Share 1 shows ✓ with green background when `share1InKeychain` is `true` 239 + - Share 1 shows ✗ with red background when `share1InKeychain` is `false` 240 + - Share 2 always shows ✓ with green background 241 + - Share 3 always shows clipboard icon with grey background 242 + - Back button returns to home 243 + 244 + **Commit:** 245 + ```bash 246 + git add apps/identity-wallet/src/lib/components/home/RecoveryInfoScreen.svelte 247 + git commit -m "feat: add RecoveryInfoScreen component showing Shamir share status" 248 + ``` 249 + <!-- END_TASK_1 --> 250 + <!-- END_SUBCOMPONENT_A -->
+291
docs/implementation-plans/2026-03-27-MM-150/test-requirements.md
··· 1 + # MM-150: Test Requirements 2 + 3 + **Ticket:** MM-150 — Wallet Home Screen: Identity Overview + Session Status 4 + **Design plan:** `docs/design-plans/2026-03-27-MM-150.md` 5 + **Last verified:** 2026-03-27 6 + 7 + --- 8 + 9 + ## Acceptance Criteria Index 10 + 11 + Every acceptance criterion from the design plan, mapped to its implementing phase and test strategy. 12 + 13 + ### MM-150.AC1: Identity card displays correctly 14 + 15 + | ID | Criterion | Phase | Test Strategy | 16 + |----|-----------|-------|---------------| 17 + | MM-150.AC1.1 | Home screen shows the user's handle from `getSession` response | Phase 3 | Human Verification | 18 + | MM-150.AC1.2 | DID is displayed truncated as `did:plc:XXXXXXXX...XXXXXX` (first 8 + last 6 of method-specific part) | Phase 3 | Human Verification | 19 + | MM-150.AC1.3 | Copy button copies the full untruncated DID to clipboard | Phase 3 | Human Verification | 20 + | MM-150.AC1.4 | Email from `getSession` is shown | Phase 3 | Human Verification | 21 + | MM-150.AC1.5 | DID-derived avatar circle is visible with a stable hue derived from the DID hash | Phase 2 | Human Verification | 22 + | MM-150.AC1.6 | Avatar shows the first letter of the handle as its initial | Phase 2 | Human Verification | 23 + | MM-150.AC1.7 | Avatar shows `?` when handle is `handle.invalid` | Phase 2 | Human Verification | 24 + | MM-150.AC1.8 | Loading spinner is shown while `loadHomeData()` is in flight | Phase 3 | Human Verification | 25 + 26 + ### MM-150.AC2: Status indicators are accurate 27 + 28 + | ID | Criterion | Phase | Test Strategy | 29 + |----|-----------|-------|---------------| 30 + | MM-150.AC2.1 | Relay status shows Connected when `_health` returns 200 | Phase 1 | Automated Test Coverage Required | 31 + | MM-150.AC2.2 | Relay status shows Error when `_health` returns non-200 or network fails | Phase 1 | Automated Test Coverage Required | 32 + | MM-150.AC2.3 | Session status shows Active when `getSession` succeeds | Phase 1 | Automated Test Coverage Required | 33 + | MM-150.AC2.4 | Session status shows Error when `getSession` fails after OAuthClient refresh attempt | Phase 1 | Automated Test Coverage Required | 34 + | MM-150.AC2.5 | Relay and session statuses are independent (one can be error while other is active) | Phase 1 | Automated Test Coverage Required | 35 + 36 + ### MM-150.AC3: Three action flows work 37 + 38 + | ID | Criterion | Phase | Test Strategy | 39 + |----|-----------|-------|---------------| 40 + | MM-150.AC3.1 | Log out clears `oauth-access-token`, `oauth-refresh-token`, and `did` from Keychain | Phase 1 | Automated Test Coverage Required | 41 + | MM-150.AC3.2 | Log out navigates to the welcome screen | Phase 3, 4 | Human Verification | 42 + | MM-150.AC3.3 | Device key and DPoP key remain in Keychain after logout | Phase 1 | Automated Test Coverage Required | 43 + | MM-150.AC3.4 | Tapping View DID Document navigates to `did_document` step | Phase 4 | Human Verification | 44 + | MM-150.AC3.5 | DID document view shows `id`, `alsoKnownAs`, `verificationMethod`, and `service` fields | Phase 5 | Human Verification | 45 + | MM-150.AC3.6 | Raw JSON toggle reveals the full DID document as a monospace block | Phase 5 | Human Verification | 46 + | MM-150.AC3.7 | Key copy button copies `publicKeyMultibase` value to clipboard | Phase 5 | Human Verification | 47 + | MM-150.AC3.8 | View DID Document button is hidden when `session.didDoc` is null | Phase 3 | Human Verification | 48 + | MM-150.AC3.9 | Back from DID document returns to home | Phase 4, 5 | Human Verification | 49 + | MM-150.AC3.10 | Tapping Recovery Info navigates to `recovery_info` step | Phase 4 | Human Verification | 50 + | MM-150.AC3.11 | Share 1 shows checkmark when `recovery-share-1` exists in Keychain | Phase 6 | Human Verification | 51 + | MM-150.AC3.12 | Share 1 shows X when `recovery-share-1` is absent from Keychain | Phase 6 | Human Verification | 52 + | MM-150.AC3.13 | Share 2 always shows checkmark (static relay custody fact) | Phase 6 | Human Verification | 53 + | MM-150.AC3.14 | Back from recovery info returns to home | Phase 4, 6 | Human Verification | 54 + 55 + ### MM-150.AC4: Tauri commands and IPC wrappers 56 + 57 + | ID | Criterion | Phase | Test Strategy | 58 + |----|-----------|-------|---------------| 59 + | MM-150.AC4.1 | `load_home_data` returns `relayHealthy: true` when `_health` returns 200 | Phase 1 | Automated Test Coverage Required | 60 + | MM-150.AC4.2 | `load_home_data` returns populated `session` when `getSession` succeeds | Phase 1 | Automated Test Coverage Required | 61 + | MM-150.AC4.3 | `load_home_data` returns `relayHealthy: false` (with `session` still populated) when `_health` fails | Phase 1 | Automated Test Coverage Required | 62 + | MM-150.AC4.4 | `load_home_data` returns `session: null` and `sessionError` populated when `getSession` fails | Phase 1 | Automated Test Coverage Required | 63 + | MM-150.AC4.5 | `load_home_data` always returns `Ok(HomeData)` — never `Err` | Phase 1 | Automated Test Coverage Required | 64 + | MM-150.AC4.6 | `log_out` deletes OAuth tokens and DID from Keychain | Phase 1 | Automated Test Coverage Required | 65 + | MM-150.AC4.7 | `log_out` always returns `Ok(())` even if Keychain delete partially fails | Phase 1 | Automated Test Coverage Required | 66 + 67 + ### MM-150.AC5: App launches to home when already onboarded 68 + 69 + | ID | Criterion | Phase | Test Strategy | 70 + |----|-----------|-------|---------------| 71 + | MM-150.AC5.1 | App starts at the `home` step when OAuth tokens exist in Keychain on launch | Phase 4 | Human Verification | 72 + | MM-150.AC5.2 | `homeData` is loaded on mount of `HomeScreen` regardless of entry path | Phase 3, 4 | Human Verification | 73 + 74 + --- 75 + 76 + ## Automated Test Coverage Required 77 + 78 + Tests are in `apps/identity-wallet/src-tauri/src/home.rs` (Phase 1, Task 4). All automated criteria target Rust unit tests with `httpmock` for HTTP endpoint mocking. 79 + 80 + | Criterion | Test File | Test Function | Verifies | 81 + |-----------|-----------|---------------|----------| 82 + | MM-150.AC2.1 | `home.rs` | `load_home_data_relay_healthy_true_when_health_returns_200` | `relay_healthy` is `true` when mock `_health` returns 200 | 83 + | MM-150.AC2.2 | `home.rs` | `load_home_data_relay_healthy_false_when_health_fails` | `relay_healthy` is `false` when mock `_health` returns 503 | 84 + | MM-150.AC2.3 | `home.rs` | `load_home_data_session_populated_when_get_session_succeeds` | `session` is `Some` with correct fields when mock `getSession` returns 200 | 85 + | MM-150.AC2.4 | `home.rs` | `load_home_data_session_null_when_get_session_fails` | `session` is `None` and `session_error` is `Some` when mock `getSession` returns 401 | 86 + | MM-150.AC2.5 | `home.rs` | `load_home_data_relay_healthy_false_when_health_fails` | `session` is populated even when relay health fails (independence verified) | 87 + | MM-150.AC2.5 | `home.rs` | `load_home_data_session_null_when_get_session_fails` | `relay_healthy` is `true` even when session fails (independence verified) | 88 + | MM-150.AC3.1 | `home.rs` | `log_out_deletes_oauth_and_did_from_keychain` | `oauth-access-token`, `oauth-refresh-token`, and `did` are absent after logout | 89 + | MM-150.AC3.3 | `home.rs` | `log_out_preserves_device_and_dpop_keys` | `oauth-dpop-key-priv` and `device-rotation-key-priv` remain after logout | 90 + | MM-150.AC4.1 | `home.rs` | `load_home_data_relay_healthy_true_when_health_returns_200` | `HomeData.relay_healthy == true` when `_health` returns 200 | 91 + | MM-150.AC4.2 | `home.rs` | `load_home_data_session_populated_when_get_session_succeeds` | `HomeData.session` contains correct `did`, `handle`, `email`, `emailConfirmed` | 92 + | MM-150.AC4.3 | `home.rs` | `load_home_data_relay_healthy_false_when_health_fails` | `relay_healthy == false` while `session` is still populated | 93 + | MM-150.AC4.4 | `home.rs` | `load_home_data_session_null_when_get_session_fails` | `session == None`, `session_error == Some(...)` when `getSession` returns 401 | 94 + | MM-150.AC4.5 | `home.rs` | `load_home_data_no_session_returns_not_authenticated` | Function returns `HomeData` (not `Err`) when no session exists in AppState | 95 + | MM-150.AC4.6 | `home.rs` | `log_out_deletes_oauth_and_did_from_keychain` | Three Keychain items deleted, AppState cleared | 96 + | MM-150.AC4.7 | `home.rs` | `log_out_succeeds_when_keychain_items_absent` | Function completes without panic when items are already absent | 97 + 98 + Additional serialization tests (not mapped to specific ACs but validate the IPC contract): 99 + 100 + | Test Function | Verifies | 101 + |---------------|----------| 102 + | `home_data_serializes_camel_case` | `HomeData` and `SessionInfo` serialize to camelCase keys matching TypeScript types | 103 + | `home_data_session_null_serializes_error_code` | `session: null` + `sessionError` serialization matches frontend expectations | 104 + 105 + --- 106 + 107 + ## Human Verification Required 108 + 109 + All UI component criteria (Phases 2-6) and navigation wiring (Phase 4) require human verification on the iOS Simulator because this project has no browser-based component test harness. The Svelte components render inside a Tauri WKWebView on iOS, so DOM-level testing frameworks are not available. 110 + 111 + ### Phase 2: DIDAvatar Component 112 + 113 + | Criterion | Why Manual | Steps | 114 + |-----------|------------|-------| 115 + | MM-150.AC1.5 | Visual rendering: hue derived from DID hash produces a colored circle | 1. Complete onboarding so the app reaches the home screen. 2. Observe the avatar circle in the identity card. 3. Verify it shows a solid-color circle (not white, not black). 4. Force-quit and relaunch the app. 5. Verify the avatar color is identical to step 3 (stable hue). | 116 + | MM-150.AC1.6 | Visual rendering: handle initial displayed inside avatar | 1. On the home screen, check the letter inside the avatar circle. 2. Verify it matches the first character of the handle shown below it (uppercased). For example, if handle is `alice.test`, the avatar shows `A`. | 117 + | MM-150.AC1.7 | Edge case requiring relay to return `handle.invalid` | 1. Create an account without registering a handle (if possible), or mock the relay to return `handle.invalid` as the handle. 2. Navigate to the home screen. 3. Verify the avatar shows `?` instead of a letter. | 118 + 119 + ### Phase 3: HomeScreen Component 120 + 121 + | Criterion | Why Manual | Steps | 122 + |-----------|------------|-------| 123 + | MM-150.AC1.1 | UI rendering of session data | 1. Complete onboarding with handle `testuser.test`. 2. On the home screen, verify the identity card shows `@testuser.test`. | 124 + | MM-150.AC1.2 | DID truncation display | 1. On the home screen, read the DID string in the identity card. 2. Verify it shows `did:plc:` followed by 8 characters, an ellipsis, and 6 characters (e.g., `did:plc:abcdefgh...uvwxyz`). 3. The full prefix `did:plc:` must be visible. | 125 + | MM-150.AC1.3 | Clipboard interaction on iOS | 1. On the home screen, tap the DID copy button (labeled "Copy"). 2. Verify the button text changes to "Copied!" for approximately 2 seconds. 3. Open Notes or another app and paste. 4. Verify the pasted text is the full untruncated DID (e.g., `did:plc:abcdefghijklmnopqrstuvwx`). | 126 + | MM-150.AC1.4 | UI rendering of email | 1. On the home screen, verify the email address displayed in the identity card matches the email used during registration. | 127 + | MM-150.AC1.8 | Loading spinner timing | 1. Launch the app (or tap the refresh button on the home screen). 2. Observe that a spinner with "Loading..." text appears briefly before the identity card renders. On a fast connection this may be very brief. | 128 + | MM-150.AC3.2 | Navigation after logout | 1. On the home screen, tap "Log Out". 2. Verify the app navigates to the welcome screen (the initial onboarding entry point). 3. Verify no identity data is visible on the welcome screen. | 129 + | MM-150.AC3.8 | Conditional button visibility | 1. If the relay has not published a DID document for the account, the home screen should NOT show a "View DID Document" button. 2. If a DID document exists (standard onboarding flow), the button should be visible. | 130 + 131 + ### Phase 4: State Machine Wiring 132 + 133 + | Criterion | Why Manual | Steps | 134 + |-----------|------------|-------| 135 + | MM-150.AC3.4 | Navigation to DID document screen | 1. On the home screen, verify the "View DID Document" button is present (requires `didDoc` to be non-null). 2. Tap "View DID Document". 3. Verify the app transitions to the DID Document screen (header says "DID Document"). | 136 + | MM-150.AC3.9 | Back navigation from DID document | 1. On the DID Document screen, tap the "Back" button. 2. Verify the app returns to the home screen with identity card intact. | 137 + | MM-150.AC3.10 | Navigation to recovery info screen | 1. On the home screen, tap "Recovery Info". 2. Verify the app transitions to the Recovery Info screen (header says "Recovery Info"). | 138 + | MM-150.AC3.14 | Back navigation from recovery info | 1. On the Recovery Info screen, tap the "Back" button. 2. Verify the app returns to the home screen. | 139 + | MM-150.AC5.1 | App startup with existing tokens | 1. Complete full onboarding so OAuth tokens are stored in Keychain. 2. Force-quit the app completely. 3. Relaunch the app. 4. Verify the app opens directly to the home screen (not the welcome screen). | 140 + | MM-150.AC5.2 | `homeData` loads regardless of entry path | 1. Complete onboarding (app should navigate to home after the `complete` step). Verify identity card is populated. 2. Force-quit and relaunch. Verify identity card is populated again (loaded on mount). | 141 + 142 + ### Phase 5: DIDDocumentScreen Component 143 + 144 + | Criterion | Why Manual | Steps | 145 + |-----------|------------|-------| 146 + | MM-150.AC3.5 | Structured DID document rendering | 1. Navigate to the DID Document screen. 2. Verify the "Identifier" section shows the full DID. 3. Verify "Also Known As" shows `at://handle` entries (if present). 4. Verify "Verification Keys" shows one or more key cards with type (e.g., "Multikey") and truncated `publicKeyMultibase`. 5. Verify "Services" shows service type and endpoint URL. | 147 + | MM-150.AC3.6 | Raw JSON toggle | 1. On the DID Document screen, tap "Show Raw JSON". 2. Verify a monospace code block appears showing the full JSON document with proper indentation. 3. Tap "Hide Raw JSON". 4. Verify the raw block disappears. | 148 + | MM-150.AC3.7 | Key copy button | 1. On the DID Document screen, find a verification key card. 2. Tap the "Copy" button next to the `publicKeyMultibase` value. 3. Verify the button text changes to "Copied!" for approximately 2 seconds. 4. Paste in Notes or another app. 5. Verify the pasted text is the full `publicKeyMultibase` string (not the truncated display). | 149 + 150 + ### Phase 6: RecoveryInfoScreen Component 151 + 152 + | Criterion | Why Manual | Steps | 153 + |-----------|------------|-------| 154 + | MM-150.AC3.11 | Share 1 present indicator | 1. Complete onboarding (which stores `recovery-share-1` in Keychain). 2. Navigate to Recovery Info. 3. Verify Share 1 row shows a green checkmark icon and text "Saved to iCloud Keychain". | 155 + | MM-150.AC3.12 | Share 1 absent indicator | 1. Manually delete `recovery-share-1` from the Keychain (requires Xcode Keychain debugging or a test helper). 2. Return to the home screen and tap refresh. 3. Navigate to Recovery Info. 4. Verify Share 1 row shows a red X icon and text "Not found in Keychain". | 156 + | MM-150.AC3.13 | Share 2 static indicator | 1. Navigate to Recovery Info. 2. Verify Share 2 row always shows a green checkmark icon and text "Held by the relay". | 157 + 158 + --- 159 + 160 + ## End-to-End Scenarios 161 + 162 + ### E2E-1: Full onboarding to home screen 163 + 164 + **Purpose:** Validates the complete user journey from first launch through account creation to the home screen, exercising Phases 1-4. 165 + 166 + | Step | Action | Expected | 167 + |------|--------|----------| 168 + | 1 | Launch the app on a fresh iOS Simulator (no prior Keychain data) | Welcome screen appears | 169 + | 2 | Complete the full onboarding flow (claim code, email, handle, password, DID ceremony, Shamir backup) | Each step transitions correctly | 170 + | 3 | After the `complete` step, observe the transition | App navigates to the home screen | 171 + | 4 | Verify identity card | Handle, truncated DID, email, and colored avatar are all displayed | 172 + | 5 | Verify status indicators | Relay shows "Connected" (green dot), Session shows "Active" (green dot) | 173 + | 6 | Verify action buttons | "View DID Document", "Recovery Info", and "Log Out" buttons are visible | 174 + 175 + ### E2E-2: Home screen to DID document and back 176 + 177 + **Purpose:** Validates DID Document navigation round-trip, exercising Phases 4-5. 178 + 179 + | Step | Action | Expected | 180 + |------|--------|----------| 181 + | 1 | From the home screen, tap "View DID Document" | DID Document screen appears with "DID Document" header | 182 + | 2 | Verify structured content | Identifier, verification keys, and services sections are populated | 183 + | 3 | Tap "Show Raw JSON" | Monospace JSON block appears below the structured view | 184 + | 4 | Tap "Hide Raw JSON" | JSON block disappears | 185 + | 5 | Tap the "Copy" button on a verification key | Button text changes to "Copied!" | 186 + | 6 | Tap "Back" | Returns to the home screen with all data intact | 187 + 188 + ### E2E-3: Home screen to recovery info and back 189 + 190 + **Purpose:** Validates Recovery Info navigation round-trip, exercising Phases 4 and 6. 191 + 192 + | Step | Action | Expected | 193 + |------|--------|----------| 194 + | 1 | From the home screen, tap "Recovery Info" | Recovery Info screen appears with "Recovery Info" header | 195 + | 2 | Verify Share 1 | Green checkmark, "Saved to iCloud Keychain" | 196 + | 3 | Verify Share 2 | Green checkmark, "Held by the relay" | 197 + | 4 | Verify Share 3 | Clipboard icon, "Your manual backup" | 198 + | 5 | Tap "Back" | Returns to the home screen with all data intact | 199 + 200 + ### E2E-4: Logout and re-authentication 201 + 202 + **Purpose:** Validates that logout clears tokens and the app returns to a clean state, exercising Phases 1, 3, and 4. 203 + 204 + | Step | Action | Expected | 205 + |------|--------|----------| 206 + | 1 | From the home screen, tap "Log Out" | App navigates to the welcome screen | 207 + | 2 | Force-quit and relaunch the app | Welcome screen appears (not home screen), confirming tokens were cleared | 208 + | 3 | Complete the OAuth login flow again | App navigates to the home screen with fresh session data | 209 + 210 + ### E2E-5: App relaunch with existing session 211 + 212 + **Purpose:** Validates AC5.1 — the app launches to home when already onboarded. 213 + 214 + | Step | Action | Expected | 215 + |------|--------|----------| 216 + | 1 | Start from a state where onboarding is complete and OAuth tokens exist | Home screen is showing | 217 + | 2 | Force-quit the app completely | App is terminated | 218 + | 3 | Relaunch the app | App opens directly to the home screen (not welcome) | 219 + | 4 | Verify identity card is populated | Handle, DID, email, and avatar are all displayed | 220 + 221 + ### E2E-6: Refresh button 222 + 223 + **Purpose:** Validates that the refresh button re-fetches data without navigating away. 224 + 225 + | Step | Action | Expected | 226 + |------|--------|----------| 227 + | 1 | On the home screen, note the current identity card data | Data is displayed | 228 + | 2 | Tap the refresh button (top-right corner) | Loading spinner appears briefly, then data re-renders | 229 + | 3 | Verify data is unchanged | Same handle, DID, email, and status indicators | 230 + 231 + --- 232 + 233 + ## Traceability Matrix 234 + 235 + Every acceptance criterion mapped to its automated test and/or manual verification step. 236 + 237 + | Acceptance Criterion | Automated Test | Manual Step | 238 + |----------------------|----------------|-------------| 239 + | MM-150.AC1.1 | -- | Phase 3: verify handle in identity card | 240 + | MM-150.AC1.2 | -- | Phase 3: verify DID truncation format | 241 + | MM-150.AC1.3 | -- | Phase 3: tap copy, paste in another app | 242 + | MM-150.AC1.4 | -- | Phase 3: verify email in identity card | 243 + | MM-150.AC1.5 | -- | Phase 2: verify colored circle, stable across relaunches | 244 + | MM-150.AC1.6 | -- | Phase 2: verify handle initial in avatar | 245 + | MM-150.AC1.7 | -- | Phase 2: verify `?` for `handle.invalid` | 246 + | MM-150.AC1.8 | -- | Phase 3: observe spinner during load | 247 + | MM-150.AC2.1 | `load_home_data_relay_healthy_true_when_health_returns_200` | -- | 248 + | MM-150.AC2.2 | `load_home_data_relay_healthy_false_when_health_fails` | -- | 249 + | MM-150.AC2.3 | `load_home_data_session_populated_when_get_session_succeeds` | -- | 250 + | MM-150.AC2.4 | `load_home_data_session_null_when_get_session_fails` | -- | 251 + | MM-150.AC2.5 | `load_home_data_relay_healthy_false_when_health_fails` + `load_home_data_session_null_when_get_session_fails` | -- | 252 + | MM-150.AC3.1 | `log_out_deletes_oauth_and_did_from_keychain` | -- | 253 + | MM-150.AC3.2 | -- | Phase 3/4: tap Log Out, verify welcome screen | 254 + | MM-150.AC3.3 | `log_out_preserves_device_and_dpop_keys` | -- | 255 + | MM-150.AC3.4 | -- | Phase 4: tap View DID Document, verify navigation | 256 + | MM-150.AC3.5 | -- | Phase 5: verify structured sections | 257 + | MM-150.AC3.6 | -- | Phase 5: toggle raw JSON | 258 + | MM-150.AC3.7 | -- | Phase 5: copy key, paste to verify | 259 + | MM-150.AC3.8 | -- | Phase 3: verify button hidden when `didDoc` is null | 260 + | MM-150.AC3.9 | -- | Phase 4/5: tap Back from DID document | 261 + | MM-150.AC3.10 | -- | Phase 4: tap Recovery Info, verify navigation | 262 + | MM-150.AC3.11 | -- | Phase 6: verify green checkmark for Share 1 | 263 + | MM-150.AC3.12 | -- | Phase 6: verify red X for Share 1 absent | 264 + | MM-150.AC3.13 | -- | Phase 6: verify green checkmark for Share 2 | 265 + | MM-150.AC3.14 | -- | Phase 4/6: tap Back from recovery info | 266 + | MM-150.AC4.1 | `load_home_data_relay_healthy_true_when_health_returns_200` | -- | 267 + | MM-150.AC4.2 | `load_home_data_session_populated_when_get_session_succeeds` | -- | 268 + | MM-150.AC4.3 | `load_home_data_relay_healthy_false_when_health_fails` | -- | 269 + | MM-150.AC4.4 | `load_home_data_session_null_when_get_session_fails` | -- | 270 + | MM-150.AC4.5 | `load_home_data_no_session_returns_not_authenticated` | -- | 271 + | MM-150.AC4.6 | `log_out_deletes_oauth_and_did_from_keychain` | -- | 272 + | MM-150.AC4.7 | `log_out_succeeds_when_keychain_items_absent` | -- | 273 + | MM-150.AC5.1 | -- | Phase 4: force-quit, relaunch, verify home | 274 + | MM-150.AC5.2 | -- | Phase 3/4: verify data loads on mount from both entry paths | 275 + 276 + --- 277 + 278 + ## Summary 279 + 280 + - **Total acceptance criteria:** 33 281 + - **Automated test coverage:** 14 criteria (all in Phase 1, Rust unit tests in `home.rs`) 282 + - **Human verification required:** 19 criteria (UI rendering, clipboard, navigation, iOS Keychain state) 283 + - **End-to-end scenarios:** 6 284 + 285 + ### Prerequisites for Human Verification 286 + 287 + - macOS with Xcode installed 288 + - iOS Simulator available (iPhone target) 289 + - `cargo tauri ios dev` running successfully 290 + - A relay instance accessible from the simulator (local or remote) 291 + - `cargo test -p identity-wallet` passing (all Phase 1 automated tests green)
+128
docs/implementation-plans/2026-03-27-relay-url-config/phase_01.md
··· 1 + # Relay URL Configuration — Phase 1: RelayClient Runtime URL Support 2 + 3 + **Goal:** Make `RelayClient` accept a runtime URL while keeping the codebase compiling. 4 + 5 + **Architecture:** Purely additive changes to `http.rs`. Change the `base_url` field from `&'static str` to `String` and add a `new_with_url` constructor. The existing static `base_url()` method is left intact in Phase 1 so all callers in `oauth.rs` and `oauth_client.rs` continue to compile unchanged. Phase 2 removes the static method and updates all callers. 6 + 7 + **Tech Stack:** Rust (stable), no new dependencies 8 + 9 + **Scope:** 1 of 4 phases 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + Infrastructure phase — no ACs tested here. 18 + 19 + **Verifies: None** — this phase modifies the `RelayClient` struct. Correctness is verified by `cargo build` succeeding and existing tests passing unchanged. 20 + 21 + --- 22 + 23 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 24 + <!-- START_TASK_1 --> 25 + ### Task 1: Update `RelayClient` struct and constructors in `http.rs` 26 + 27 + **Files:** 28 + - Modify: `apps/identity-wallet/src-tauri/src/http.rs` 29 + 30 + **Step 1: Change the `base_url` field type** 31 + 32 + At `http.rs:44`, change: 33 + ```rust 34 + base_url: &'static str, 35 + ``` 36 + to: 37 + ```rust 38 + base_url: String, 39 + ``` 40 + 41 + **Step 2: Update `RelayClient::new()` to use `.to_string()`** 42 + 43 + At `http.rs:49-54`, change: 44 + ```rust 45 + pub fn new() -> Self { 46 + Self { 47 + client: Client::new(), 48 + base_url: RELAY_BASE_URL, 49 + } 50 + } 51 + ``` 52 + to: 53 + ```rust 54 + pub fn new() -> Self { 55 + Self { 56 + client: Client::new(), 57 + base_url: RELAY_BASE_URL.to_string(), 58 + } 59 + } 60 + ``` 61 + 62 + **Step 3: Add `new_with_url` constructor** 63 + 64 + Insert this method directly after `new()` (after the closing `}` of `new()`, before the `post` method): 65 + ```rust 66 + /// Create a new `RelayClient` with a runtime-provided base URL. 67 + /// 68 + /// The URL must not have a trailing slash. Used when the relay URL is 69 + /// configured at runtime rather than baked in at compile time. 70 + pub fn new_with_url(url: String) -> Self { 71 + Self { 72 + client: Client::new(), 73 + base_url: url, 74 + } 75 + } 76 + ``` 77 + 78 + **Step 4: Add an instance `base_url` accessor method** 79 + 80 + The existing static `base_url()` method at `http.rs:195` returns the compile-time constant and is kept unchanged. Add a new instance method after it: 81 + 82 + ```rust 83 + /// Returns the base URL for this relay client instance. 84 + pub fn base_url_str(&self) -> &str { 85 + &self.base_url 86 + } 87 + ``` 88 + 89 + > Note: The method is named `base_url_str` (not `base_url`) to avoid a name collision with the existing static `const fn base_url() -> &'static str`. The static method is removed and callers updated in Phase 2. 90 + 91 + **Step 5: Verify the file still compiles** 92 + 93 + From the workspace root (inside the Nix dev shell): 94 + ```bash 95 + cargo build -p identity-wallet-lib 2>&1 | head -40 96 + ``` 97 + Expected: No errors. Warnings about unused `new_with_url` or `base_url_str` are fine. 98 + 99 + If `cargo build -p identity-wallet-lib` fails (package name mismatch), use: 100 + ```bash 101 + cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40 102 + ``` 103 + <!-- END_TASK_1 --> 104 + 105 + <!-- START_TASK_2 --> 106 + ### Task 2: Verify existing tests still pass and commit 107 + 108 + **Files:** 109 + - No new files 110 + 111 + **Step 1: Run all tests in the identity-wallet crate** 112 + 113 + ```bash 114 + cargo test --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 115 + ``` 116 + 117 + Expected: All tests pass (there are no tests in `http.rs`; the 31 tests in `lib.rs` and the `oauth_client.rs` tests should all still pass since no signatures visible to them have changed). 118 + 119 + > Note: Some tests in `oauth.rs` are marked `#[ignore]` (integration tests that need a live server). These will be skipped and that is expected. 120 + 121 + **Step 2: Commit** 122 + 123 + ```bash 124 + git add apps/identity-wallet/src-tauri/src/http.rs 125 + git commit -m "refactor: add RelayClient::new_with_url and base_url_str for runtime URL support" 126 + ``` 127 + <!-- END_TASK_2 --> 128 + <!-- END_SUBCOMPONENT_A -->
+510
docs/implementation-plans/2026-03-27-relay-url-config/phase_02.md
··· 1 + # Relay URL Configuration — Phase 2: AppState Integration and Command Migration 2 + 3 + **Goal:** Remove the `RELAY_CLIENT` global static and route all relay access through `AppState`, while keeping the app fully functional using the compile-time default URL. 4 + 5 + **Architecture:** Add `relay_client: OnceLock<RelayClient>` to `AppState` (initialized to default until Phase 3 adds Keychain loading). Update all four commands that use `RELAY_CLIENT` to accept `state: tauri::State<'_, AppState>`. Update `start_oauth_flow` and `OAuthClient::new()` to get the URL from state. The app continues to work with the compile-time default URL throughout this phase. 6 + 7 + **Tech Stack:** Rust (stable), no new dependencies 8 + 9 + **Scope:** 2 of 4 phases 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + Infrastructure/refactor phase — no new ACs tested here. 18 + 19 + **Verifies: None** — this phase is a mechanical refactor. Correctness is verified by `cargo build` succeeding, all existing tests passing, and no references to `RELAY_CLIENT` remaining. 20 + 21 + --- 22 + 23 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 24 + <!-- START_TASK_1 --> 25 + ### Task 1: Add `relay_client` to `AppState` in `oauth.rs` and add a `default_relay_url` helper in `http.rs` 26 + 27 + **Files:** 28 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` 29 + - Modify: `apps/identity-wallet/src-tauri/src/http.rs` 30 + 31 + **Step 1: Add the `relay_client` field and methods to `AppState` (`oauth.rs`)** 32 + 33 + At the top of the file there should already be a `use std::sync::Mutex;` import. Add `OnceLock` to the import. Find the existing `use std::sync::Mutex;` import and change it to: 34 + 35 + ```rust 36 + use std::sync::{Mutex, OnceLock}; 37 + ``` 38 + 39 + Then update the `AppState` struct (currently at lines 17–28) to add the new field: 40 + 41 + ```rust 42 + pub struct AppState { 43 + /// The pending OAuth flow waiting for the deep-link callback. 44 + /// Set by `start_oauth_flow` before opening Safari; cleared by `handle_deep_link`. 45 + pub pending_auth: Mutex<Option<PendingOAuthFlow>>, 46 + /// The active authenticated session after a successful token exchange. 47 + /// Set by `start_oauth_flow` on success; read by `OAuthClient` for every request. 48 + pub oauth_session: Mutex<Option<OAuthSession>>, 49 + /// Runtime relay client. Populated from Keychain on startup (Phase 3) or by 50 + /// `save_relay_url` on first launch. Falls back to the compile-time default if unset. 51 + relay_client: OnceLock<crate::http::RelayClient>, 52 + } 53 + ``` 54 + 55 + Update `AppState::new()` (currently at lines 30–36) to initialize the new field: 56 + 57 + ```rust 58 + impl AppState { 59 + pub fn new() -> Self { 60 + Self { 61 + pending_auth: Mutex::new(None), 62 + oauth_session: Mutex::new(None), 63 + relay_client: OnceLock::new(), 64 + } 65 + } 66 + 67 + /// Returns the configured relay client, or initializes with the compile-time 68 + /// default URL if none has been set yet. 69 + pub fn relay_client(&self) -> &crate::http::RelayClient { 70 + self.relay_client 71 + .get_or_init(crate::http::RelayClient::new) 72 + } 73 + 74 + /// Set the relay client from a runtime URL. Silently ignored if already set 75 + /// (OnceLock::set semantics — this is only called once on first launch). 76 + pub fn set_relay_client(&self, url: String) { 77 + self.relay_client 78 + .set(crate::http::RelayClient::new_with_url(url)) 79 + .ok(); 80 + } 81 + } 82 + ``` 83 + 84 + The `Default` impl at lines 39–42 can remain unchanged (it calls `Self::new()`). 85 + 86 + **Step 2: Add `pub fn default_relay_url()` to `http.rs`** 87 + 88 + This free function is used by tests and will be used by the frontend default in Phase 3. Add it after the `RELAY_BASE_URL` constants (after line 15): 89 + 90 + ```rust 91 + /// Returns the compile-time default relay base URL. 92 + /// 93 + /// Used by integration tests and as the pre-filled default in the relay 94 + /// configuration UI. The runtime URL (from Keychain or user input) takes 95 + /// precedence during normal app operation. 96 + pub fn default_relay_url() -> &'static str { 97 + RELAY_BASE_URL 98 + } 99 + ``` 100 + 101 + **Step 3: Verify compilation** 102 + 103 + ```bash 104 + cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40 105 + ``` 106 + 107 + Expected: Compiles (with possible dead-code warnings for the new methods, which is fine). 108 + <!-- END_TASK_1 --> 109 + 110 + <!-- START_TASK_2 --> 111 + ### Task 2: Remove `RELAY_CLIENT` static from `lib.rs` and update all four commands 112 + 113 + **Files:** 114 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 115 + 116 + **Step 1: Remove the static declaration** 117 + 118 + Delete these lines from `lib.rs` (currently lines 227–229): 119 + 120 + ```rust 121 + // ── Static relay client ───────────────────────────────────────────────────── 122 + 123 + static RELAY_CLIENT: LazyLock<http::RelayClient> = LazyLock::new(http::RelayClient::new); 124 + ``` 125 + 126 + Also remove the `LazyLock` import at the top of the file. Find and remove `LazyLock` from the `use std::sync::LazyLock;` import line (or remove the entire import if `LazyLock` is the only thing imported from it). 127 + 128 + **Step 2: Update `create_account`** 129 + 130 + Add `state: tauri::State<'_, oauth::AppState>` as the last parameter and replace the `RELAY_CLIENT` call: 131 + 132 + Before (lines 247–252): 133 + ```rust 134 + #[tauri::command] 135 + async fn create_account( 136 + claim_code: String, 137 + email: String, 138 + handle: String, 139 + ) -> Result<CreateAccountResult, CreateAccountError> { 140 + ``` 141 + 142 + After: 143 + ```rust 144 + #[tauri::command] 145 + async fn create_account( 146 + claim_code: String, 147 + email: String, 148 + handle: String, 149 + state: tauri::State<'_, oauth::AppState>, 150 + ) -> Result<CreateAccountResult, CreateAccountError> { 151 + ``` 152 + 153 + Replace `RELAY_CLIENT` at line 268: 154 + ```rust 155 + let resp = RELAY_CLIENT 156 + .post("/v1/accounts/mobile", &req) 157 + ``` 158 + becomes: 159 + ```rust 160 + let resp = state 161 + .relay_client() 162 + .post("/v1/accounts/mobile", &req) 163 + ``` 164 + 165 + **Step 3: Update `perform_did_ceremony`** 166 + 167 + Add `state: tauri::State<'_, oauth::AppState>` as the last parameter: 168 + 169 + Before (lines 332–336): 170 + ```rust 171 + #[tauri::command] 172 + async fn perform_did_ceremony( 173 + handle: String, 174 + password: String, 175 + ) -> Result<DIDCeremonyResult, DIDCeremonyError> { 176 + ``` 177 + 178 + After: 179 + ```rust 180 + #[tauri::command] 181 + async fn perform_did_ceremony( 182 + handle: String, 183 + password: String, 184 + state: tauri::State<'_, oauth::AppState>, 185 + ) -> Result<DIDCeremonyResult, DIDCeremonyError> { 186 + ``` 187 + 188 + Replace `RELAY_CLIENT` at line 345: 189 + ```rust 190 + let resp = 191 + RELAY_CLIENT 192 + .get("/v1/relay/keys") 193 + ``` 194 + becomes: 195 + ```rust 196 + let resp = 197 + state 198 + .relay_client() 199 + .get("/v1/relay/keys") 200 + ``` 201 + 202 + Replace `http::RelayClient::base_url()` at line 379: 203 + ```rust 204 + http::RelayClient::base_url(), 205 + ``` 206 + becomes: 207 + ```rust 208 + state.relay_client().base_url_str(), 209 + ``` 210 + 211 + Replace `RELAY_CLIENT` at line 410: 212 + ```rust 213 + let resp = RELAY_CLIENT 214 + .post_with_bearer("/v1/dids", &create_did_req, &pending_token) 215 + ``` 216 + becomes: 217 + ```rust 218 + let resp = state 219 + .relay_client() 220 + .post_with_bearer("/v1/dids", &create_did_req, &pending_token) 221 + ``` 222 + 223 + **Step 4: Update `register_handle`** 224 + 225 + Add `state: tauri::State<'_, oauth::AppState>` as the last parameter: 226 + 227 + Before (lines 472–475): 228 + ```rust 229 + #[tauri::command] 230 + async fn register_handle( 231 + handle_label: String, 232 + ) -> Result<RegisterHandleResult, RegisterHandleError> { 233 + ``` 234 + 235 + After: 236 + ```rust 237 + #[tauri::command] 238 + async fn register_handle( 239 + handle_label: String, 240 + state: tauri::State<'_, oauth::AppState>, 241 + ) -> Result<RegisterHandleResult, RegisterHandleError> { 242 + ``` 243 + 244 + Replace `RELAY_CLIENT` at line 477: 245 + ```rust 246 + let resp = RELAY_CLIENT 247 + .get("/xrpc/com.atproto.server.describeServer") 248 + ``` 249 + becomes: 250 + ```rust 251 + let resp = state 252 + .relay_client() 253 + .get("/xrpc/com.atproto.server.describeServer") 254 + ``` 255 + 256 + Replace `RELAY_CLIENT` at line 531: 257 + ```rust 258 + let resp = RELAY_CLIENT 259 + .post_with_bearer("/v1/handles", &req, &session_token) 260 + ``` 261 + becomes: 262 + ```rust 263 + let resp = state 264 + .relay_client() 265 + .post_with_bearer("/v1/handles", &req, &session_token) 266 + ``` 267 + 268 + **Step 5: Update `check_handle_resolution`** 269 + 270 + Add `state: tauri::State<'_, oauth::AppState>` as the last parameter: 271 + 272 + Before (line 585): 273 + ```rust 274 + #[tauri::command] 275 + async fn check_handle_resolution(handle: String, expected_did: String) -> bool { 276 + ``` 277 + 278 + After: 279 + ```rust 280 + #[tauri::command] 281 + async fn check_handle_resolution( 282 + handle: String, 283 + expected_did: String, 284 + state: tauri::State<'_, oauth::AppState>, 285 + ) -> bool { 286 + ``` 287 + 288 + Replace `RELAY_CLIENT` at line 589: 289 + ```rust 290 + let resp = match RELAY_CLIENT.get(&path).await { 291 + ``` 292 + becomes: 293 + ```rust 294 + let resp = match state.relay_client().get(&path).await { 295 + ``` 296 + 297 + **Step 6: Verify compilation** 298 + 299 + ```bash 300 + cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40 301 + ``` 302 + 303 + Expected: Compiles. If there are errors about `LazyLock` being unused or not found, double-check the import removal in Step 1. 304 + <!-- END_TASK_2 --> 305 + <!-- END_SUBCOMPONENT_A --> 306 + 307 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 308 + <!-- START_TASK_3 --> 309 + ### Task 3: Update `OAuthClient::new()` in `oauth_client.rs` and its call site in `home.rs` 310 + 311 + **Files:** 312 + - Modify: `apps/identity-wallet/src-tauri/src/oauth_client.rs` 313 + - Modify: `apps/identity-wallet/src-tauri/src/home.rs` 314 + 315 + **Step 1: Add `base_url` parameter to `OAuthClient::new()` (`oauth_client.rs`)** 316 + 317 + Current `new()` at lines 37–44: 318 + ```rust 319 + pub fn new(session: Arc<Mutex<OAuthSession>>) -> Result<Self, OAuthError> { 320 + let dpop = DPoPKeypair::get_or_create()?; 321 + Ok(Self { 322 + inner: Client::new(), 323 + dpop, 324 + session, 325 + base_url: crate::http::RelayClient::base_url().to_string(), 326 + }) 327 + } 328 + ``` 329 + 330 + Replace with: 331 + ```rust 332 + pub fn new(session: Arc<Mutex<OAuthSession>>, base_url: String) -> Result<Self, OAuthError> { 333 + let dpop = DPoPKeypair::get_or_create()?; 334 + Ok(Self { 335 + inner: Client::new(), 336 + dpop, 337 + session, 338 + base_url, 339 + }) 340 + } 341 + ``` 342 + 343 + **Step 2: Update the `OAuthClient::new()` call in `home.rs`** 344 + 345 + In `home.rs` at line 83: 346 + ```rust 347 + let oauth_client = match crate::oauth_client::OAuthClient::new(session_arc.clone()) { 348 + ``` 349 + becomes: 350 + ```rust 351 + let oauth_client = match crate::oauth_client::OAuthClient::new( 352 + session_arc.clone(), 353 + state.relay_client().base_url_str().to_owned(), 354 + ) { 355 + ``` 356 + 357 + The same `state` parameter that `load_home_data` already receives (`state: tauri::State<'_, AppState>`) is used here. 358 + 359 + **Step 3: Update the private `check_relay_health` helper in `home.rs`** 360 + 361 + Rename `check_relay_health` to `ping_relay_health` (to avoid ambiguity with any future public IPC commands) and add a `relay_client` parameter. 362 + 363 + Current at lines 165–171: 364 + ```rust 365 + async fn check_relay_health() -> bool { 366 + crate::http::RelayClient::new() 367 + .get("/xrpc/_health") 368 + .await 369 + .map(|r| r.status().is_success()) 370 + .unwrap_or(false) 371 + } 372 + ``` 373 + 374 + Replace with: 375 + ```rust 376 + async fn ping_relay_health(relay_client: &crate::http::RelayClient) -> bool { 377 + relay_client 378 + .get("/xrpc/_health") 379 + .await 380 + .map(|r| r.status().is_success()) 381 + .unwrap_or(false) 382 + } 383 + ``` 384 + 385 + Update the three call sites in `load_home_data` (lines 72, 88, 97): 386 + 387 + - Line 72: `check_relay_health().await` → `ping_relay_health(state.relay_client()).await` 388 + - Line 88: `check_relay_health().await` → `ping_relay_health(state.relay_client()).await` 389 + - Line 97: `check_relay_health()` → `ping_relay_health(state.relay_client())` 390 + 391 + **Step 4: Verify compilation** 392 + 393 + ```bash 394 + cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40 395 + ``` 396 + 397 + Expected: Compiles cleanly. 398 + <!-- END_TASK_3 --> 399 + 400 + <!-- START_TASK_4 --> 401 + ### Task 4: Update `start_oauth_flow` in `oauth.rs` and fix test static calls 402 + 403 + **Files:** 404 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` 405 + 406 + **Step 1: Update `start_oauth_flow` to use `state.relay_client()`** 407 + 408 + `start_oauth_flow` at line 384 creates a local `RelayClient`: 409 + ```rust 410 + let relay = crate::http::RelayClient::new(); 411 + ``` 412 + Replace with: 413 + ```rust 414 + let relay = state.relay_client(); 415 + ``` 416 + 417 + At line 394, replace: 418 + ```rust 419 + let par_htu = format!("{}/oauth/par", crate::http::RelayClient::base_url()); 420 + ``` 421 + with: 422 + ```rust 423 + let par_htu = format!("{}/oauth/par", state.relay_client().base_url_str()); 424 + ``` 425 + 426 + At line 421, replace: 427 + ```rust 428 + let base = crate::http::RelayClient::base_url(); 429 + ``` 430 + with: 431 + ```rust 432 + let base = state.relay_client().base_url_str(); 433 + ``` 434 + 435 + At line 449, replace: 436 + ```rust 437 + let token_htu = format!("{}/oauth/token", crate::http::RelayClient::base_url()); 438 + ``` 439 + with: 440 + ```rust 441 + let token_htu = format!("{}/oauth/token", state.relay_client().base_url_str()); 442 + ``` 443 + 444 + **Step 2: Fix the two test usages of `RelayClient::base_url()` (lines 786, 818)** 445 + 446 + These are `#[ignore]` integration tests. They use `crate::http::RelayClient::base_url()` only to build URL strings for test requests. Replace them with the new `default_relay_url()` free function added in Task 1. 447 + 448 + At line 786 (inside `par_integration_returns_201_with_request_uri`): 449 + ```rust 450 + let htu = format!("{}/oauth/par", crate::http::RelayClient::base_url()); 451 + ``` 452 + becomes: 453 + ```rust 454 + let htu = format!("{}/oauth/par", crate::http::default_relay_url()); 455 + ``` 456 + 457 + At lines 818–819 (inside `par_missing_code_challenge_returns_client_error`): 458 + ```rust 459 + let base_url = crate::http::RelayClient::base_url(); 460 + let url = format!("{base_url}/oauth/par"); 461 + ``` 462 + becomes: 463 + ```rust 464 + let base_url = crate::http::default_relay_url(); 465 + let url = format!("{base_url}/oauth/par"); 466 + ``` 467 + 468 + **Step 3: Remove the now-unused static `base_url()` method from `http.rs`** 469 + 470 + In `http.rs`, delete the static `base_url()` method (currently lines 192–197): 471 + 472 + ```rust 473 + /// Returns the compile-time base URL for this relay client instance. 474 + /// 475 + /// Used as the `service_endpoint` parameter in DID ceremony genesis op construction. 476 + pub const fn base_url() -> &'static str { 477 + RELAY_BASE_URL 478 + } 479 + ``` 480 + 481 + Update the doc comment on `http.rs` at the top (lines 1–5) to remove the note about compile-time base URL, since it's now runtime-configurable. 482 + 483 + **Step 4: Run all tests** 484 + 485 + ```bash 486 + cargo test --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 487 + ``` 488 + 489 + Expected: All non-ignored tests pass. The `#[ignore]` integration tests in `oauth.rs` are skipped (expected). 490 + 491 + Verify zero references to `RELAY_CLIENT` remain: 492 + ```bash 493 + grep -r "RELAY_CLIENT" apps/identity-wallet/src-tauri/src/ 494 + ``` 495 + Expected: No output. 496 + 497 + Verify zero references to `RelayClient::base_url()` as a static call remain: 498 + ```bash 499 + grep -rn "RelayClient::base_url()" apps/identity-wallet/src-tauri/src/ 500 + ``` 501 + Expected: No output. 502 + 503 + **Step 5: Commit** 504 + 505 + ```bash 506 + git add apps/identity-wallet/src-tauri/src/ 507 + git commit -m "refactor: move RelayClient to AppState, remove RELAY_CLIENT static" 508 + ``` 509 + <!-- END_TASK_4 --> 510 + <!-- END_SUBCOMPONENT_B -->
+325
docs/implementation-plans/2026-03-27-relay-url-config/phase_03.md
··· 1 + # Relay URL Configuration — Phase 3: IPC Commands and Startup Initialization 2 + 3 + **Goal:** Expose relay URL configuration to the frontend and initialize the relay client from Keychain on startup. 4 + 5 + **Architecture:** Add two new Tauri IPC commands (`get_relay_url`, `save_relay_url`) and two Keychain helpers (`store_relay_url`, `load_relay_url`). Update the `run()` setup block to read the relay URL from Keychain and initialize AppState before the app starts receiving commands. URL validation uses the `url` crate (already in `Cargo.toml`). `save_relay_url` handles validation, health check, Keychain persistence, and AppState initialization in one command. A separate `check_relay_health` command is not needed and is not added. 6 + 7 + **Tech Stack:** Rust (stable), `url` crate (already in Cargo.toml), `reqwest` (already in Cargo.toml) 8 + 9 + **Scope:** 3 of 4 phases 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### relay-url-config.AC3: URL persists across restarts 18 + - **relay-url-config.AC3.1 Success:** After saving a URL and relaunching the app, the relay config screen is not shown 19 + - **relay-url-config.AC3.2 Success:** All relay IPC commands on subsequent launches use the saved URL 20 + 21 + ### relay-url-config.AC4: Relay reachability verified before saving 22 + - **relay-url-config.AC4.1 Success:** A URL whose `/xrpc/_health` returns HTTP 200 is accepted 23 + - **relay-url-config.AC4.2 Failure:** An unreachable host surfaces an `UNREACHABLE` inline error 24 + - **relay-url-config.AC4.3 Failure:** A malformed URL (not `http`/`https`, empty host) surfaces an `INVALID_URL` error before any network call 25 + - **relay-url-config.AC4.4 Edge:** A URL with a trailing slash is accepted and normalized (slash stripped) before saving 26 + 27 + --- 28 + 29 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 30 + <!-- START_TASK_1 --> 31 + ### Task 1: Add Keychain helpers for relay URL in `keychain.rs` 32 + 33 + **Files:** 34 + - Modify: `apps/identity-wallet/src-tauri/src/keychain.rs` 35 + 36 + **Step 1: Add the account name constant** 37 + 38 + In `keychain.rs`, find the existing account name constants (they look like `const DPOP_KEY_PRIV_ACCOUNT: &str = "..."`). Add a new constant alongside them: 39 + 40 + ```rust 41 + const RELAY_URL_ACCOUNT: &str = "relay-base-url"; 42 + ``` 43 + 44 + **Step 2: Add `store_relay_url` and `load_relay_url` helpers** 45 + 46 + Add these helper functions at the bottom of the public helper section (after the existing `store_oauth_tokens` / `load_oauth_tokens` functions): 47 + 48 + ```rust 49 + /// Persist the user-configured relay base URL to the Keychain. 50 + /// 51 + /// Overwrites any previously stored URL. 52 + pub fn store_relay_url(url: &str) -> Result<(), KeychainError> { 53 + store_item(RELAY_URL_ACCOUNT, url.as_bytes()) 54 + } 55 + 56 + /// Retrieve the user-configured relay base URL from the Keychain. 57 + /// 58 + /// Returns `None` if no URL has been saved yet (first run or after logout). 59 + pub fn load_relay_url() -> Option<String> { 60 + match get_item(RELAY_URL_ACCOUNT) { 61 + Ok(bytes) => String::from_utf8(bytes) 62 + .map_err(|e| { 63 + tracing::warn!(error = %e, "relay URL in Keychain is not valid UTF-8; treating as absent"); 64 + }) 65 + .ok(), 66 + Err(e) if is_not_found(&e) => None, 67 + Err(e) => { 68 + tracing::error!(error = ?e, "Keychain error loading relay URL"); 69 + None 70 + } 71 + } 72 + } 73 + 74 + /// Remove the relay URL from the Keychain. Test-only; used to reset state 75 + /// between tests that share the Keychain mock store. 76 + #[cfg(test)] 77 + pub fn delete_relay_url_test_only() { 78 + let _ = delete_item(RELAY_URL_ACCOUNT); 79 + } 80 + ``` 81 + 82 + **Step 3: Verify compilation** 83 + 84 + ```bash 85 + cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -20 86 + ``` 87 + 88 + Expected: Compiles. 89 + <!-- END_TASK_1 --> 90 + 91 + <!-- START_TASK_2 --> 92 + ### Task 2: Add `RelayConfigError` and the three IPC commands in `lib.rs` 93 + 94 + **Files:** 95 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 96 + 97 + **Step 1: Add `RelayConfigError`** 98 + 99 + Add this enum to `lib.rs` alongside the other error enums (e.g., after `CreateAccountError`). Follow the exact same derive pattern used by `CreateAccountError`: 100 + 101 + ```rust 102 + /// Error returned by relay URL configuration commands. 103 + /// 104 + /// Serializes as `{ "code": "INVALID_URL" | "UNREACHABLE" | "KEYCHAIN_ERROR" }` for the frontend. 105 + #[derive(Debug, Serialize, thiserror::Error)] 106 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 107 + pub enum RelayConfigError { 108 + #[error("invalid relay URL: must be http or https with a non-empty host")] 109 + InvalidUrl, 110 + #[error("relay is unreachable or did not return a success response")] 111 + Unreachable, 112 + #[error("failed to save relay URL to device storage")] 113 + KeychainError, 114 + } 115 + ``` 116 + 117 + **Step 2: Add URL validation helper** 118 + 119 + Add a private helper function near the bottom of the helpers section (near `map_409_subcode`): 120 + 121 + ```rust 122 + /// Validate a relay URL: must parse as http or https with a non-empty host. 123 + /// Strips any trailing slash and returns the normalized URL string. 124 + fn normalize_relay_url(url: &str) -> Result<String, RelayConfigError> { 125 + let parsed = url::Url::parse(url).map_err(|_| RelayConfigError::InvalidUrl)?; 126 + match parsed.scheme() { 127 + "http" | "https" => {} 128 + _ => return Err(RelayConfigError::InvalidUrl), 129 + } 130 + if parsed.host().is_none() { 131 + return Err(RelayConfigError::InvalidUrl); 132 + } 133 + Ok(url.trim_end_matches('/').to_string()) 134 + } 135 + ``` 136 + 137 + **Step 3: Add the two IPC commands** 138 + 139 + Add these two commands to `lib.rs`, grouped after the existing IPC commands (before `run()`): 140 + 141 + ```rust 142 + /// Return the saved relay base URL, or `None` if not yet configured. 143 + /// 144 + /// The frontend calls this on mount to decide whether to show the relay 145 + /// configuration screen. 146 + #[tauri::command] 147 + fn get_relay_url() -> Option<String> { 148 + keychain::load_relay_url() 149 + } 150 + 151 + /// Validate `url`, confirm the relay is reachable, save to Keychain, and 152 + /// initialize the runtime relay client. 153 + /// 154 + /// After this call succeeds, all subsequent IPC commands that use the relay 155 + /// will use the saved URL for the remainder of the app session and on all 156 + /// future launches. 157 + #[tauri::command] 158 + async fn save_relay_url( 159 + url: String, 160 + state: tauri::State<'_, oauth::AppState>, 161 + ) -> Result<(), RelayConfigError> { 162 + let normalized = normalize_relay_url(&url)?; 163 + let resp = http::RelayClient::new_with_url(normalized.clone()) 164 + .get("/xrpc/_health") 165 + .await 166 + .map_err(|_| RelayConfigError::Unreachable)?; 167 + if !resp.status().is_success() { 168 + tracing::warn!( 169 + status = %resp.status(), 170 + url = %normalized, 171 + "relay health check returned non-success status" 172 + ); 173 + return Err(RelayConfigError::Unreachable); 174 + } 175 + keychain::store_relay_url(&normalized).map_err(|e| { 176 + tracing::error!(error = %e, "failed to save relay URL to Keychain"); 177 + RelayConfigError::KeychainError 178 + })?; 179 + state.set_relay_client(normalized); 180 + Ok(()) 181 + } 182 + ``` 183 + 184 + **Step 4: Register the new commands in `invoke_handler`** 185 + 186 + Update the `tauri::generate_handler!` macro in `run()`: 187 + 188 + ```rust 189 + .invoke_handler(tauri::generate_handler![ 190 + create_account, 191 + get_or_create_device_key, 192 + sign_with_device_key, 193 + perform_did_ceremony, 194 + register_handle, 195 + check_handle_resolution, 196 + get_relay_url, 197 + save_relay_url, 198 + home::load_home_data, 199 + home::log_out, 200 + oauth::start_oauth_flow, 201 + ]) 202 + ``` 203 + 204 + **Step 5: Verify compilation** 205 + 206 + ```bash 207 + cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40 208 + ``` 209 + 210 + Expected: Compiles cleanly. 211 + <!-- END_TASK_2 --> 212 + 213 + <!-- START_TASK_3 --> 214 + ### Task 3: Initialize relay client from Keychain on startup, write tests, and commit 215 + 216 + **Files:** 217 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 218 + 219 + **Step 1: Add Keychain relay URL initialization to `run()` setup block** 220 + 221 + In the `run()` function, in the `.setup(|app| { ... })` closure, add relay URL initialization **before** the existing OAuth token restore block: 222 + 223 + ```rust 224 + .setup(|app| { 225 + // Restore relay URL from Keychain if previously configured. 226 + if let Some(url) = keychain::load_relay_url() { 227 + app.state::<oauth::AppState>().set_relay_client(url); 228 + } 229 + 230 + let app_handle = app.app_handle().clone(); 231 + app.deep_link().on_open_url(move |event| { 232 + // ... existing deep-link handler unchanged 233 + }); 234 + 235 + // ... existing OAuth token restore block unchanged 236 + ``` 237 + 238 + This ensures the relay client is configured before any IPC commands can fire, on both first launch (where `load_relay_url()` returns `None` and the default is used) and subsequent launches (where it returns the saved URL). 239 + 240 + **Step 2: Write tests** 241 + 242 + Add tests to the existing `mod tests` block at the bottom of `lib.rs`. These tests use the Keychain test mock that the existing tests already rely on. 243 + 244 + ```rust 245 + // -- normalize_relay_url -- 246 + 247 + #[test] 248 + fn normalize_relay_url_strips_trailing_slash() { 249 + assert_eq!( 250 + normalize_relay_url("https://relay.example.com/").unwrap(), 251 + "https://relay.example.com" 252 + ); 253 + } 254 + 255 + #[test] 256 + fn normalize_relay_url_accepts_http_and_https() { 257 + assert!(normalize_relay_url("https://relay.example.com").is_ok()); 258 + assert!(normalize_relay_url("http://localhost:8080").is_ok()); 259 + } 260 + 261 + #[test] 262 + fn normalize_relay_url_rejects_non_http_schemes() { 263 + assert!(matches!( 264 + normalize_relay_url("ftp://relay.example.com").unwrap_err(), 265 + RelayConfigError::InvalidUrl 266 + )); 267 + assert!(matches!( 268 + normalize_relay_url("ws://relay.example.com").unwrap_err(), 269 + RelayConfigError::InvalidUrl 270 + )); 271 + } 272 + 273 + #[test] 274 + fn normalize_relay_url_rejects_malformed_input() { 275 + assert!(matches!( 276 + normalize_relay_url("not-a-url").unwrap_err(), 277 + RelayConfigError::InvalidUrl 278 + )); 279 + assert!(matches!( 280 + normalize_relay_url("").unwrap_err(), 281 + RelayConfigError::InvalidUrl 282 + )); 283 + } 284 + 285 + // -- get_relay_url / load_relay_url round-trip -- 286 + 287 + #[test] 288 + fn get_relay_url_returns_none_before_save() { 289 + // The Keychain test mock starts empty in a fresh process; tests that 290 + // write to the store must clean up via delete_relay_url_test_only(). 291 + assert!(get_relay_url().is_none()); 292 + } 293 + 294 + #[test] 295 + fn relay_url_round_trips_through_keychain() { 296 + let url = "https://relay.example.com"; 297 + keychain::store_relay_url(url).unwrap(); 298 + let loaded = keychain::load_relay_url().unwrap(); 299 + assert_eq!(loaded, url); 300 + // Clean up so this test doesn't affect others sharing the mock store. 301 + keychain::delete_relay_url_test_only(); 302 + } 303 + ``` 304 + 305 + > Note: `save_relay_url` makes live HTTP calls and is not tested here. The URL validation path through `normalize_relay_url` is fully covered by the unit tests above. End-to-end behavior (reachability) is verified manually per the test plan. 306 + > 307 + > `delete_relay_url_test_only` is a `#[cfg(test)]` helper added in Task 1 alongside `store_relay_url` and `load_relay_url` — it calls `keychain::delete_item(RELAY_URL_ACCOUNT)` so round-trip tests can clean up after themselves without polluting other tests that expect an empty store. 308 + 309 + **Step 3: Run tests** 310 + 311 + ```bash 312 + cargo test --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 313 + ``` 314 + 315 + Expected: All tests pass, including the new ones. Look for the test names `normalize_relay_url_*`, `get_relay_url_*`, `relay_url_round_trips_*` in the output. 316 + 317 + **Step 4: Commit** 318 + 319 + ```bash 320 + git add apps/identity-wallet/src-tauri/src/keychain.rs \ 321 + apps/identity-wallet/src-tauri/src/lib.rs 322 + git commit -m "feat: add relay URL IPC commands and Keychain persistence" 323 + ``` 324 + <!-- END_TASK_3 --> 325 + <!-- END_SUBCOMPONENT_A -->
+414
docs/implementation-plans/2026-03-27-relay-url-config/phase_04.md
··· 1 + # Relay URL Configuration — Phase 4: Frontend Relay Configuration Screen 2 + 3 + **Goal:** Show the relay URL configuration screen on first launch and skip it on subsequent launches. 4 + 5 + **Architecture:** Add three IPC wrappers to `ipc.ts`, create `RelayConfigScreen.svelte`, and update `+page.svelte` to add the `relay_config` step and the mount-time check. `saveRelayUrl` performs validation, health check, Keychain persistence, and AppState initialization in a single IPC call; the screen simply calls it on tap. 6 + 7 + **Tech Stack:** SvelteKit 2, Svelte 5 runes, TypeScript, Tauri IPC 8 + 9 + **Scope:** 4 of 4 phases 10 + 11 + **Codebase verified:** 2026-03-27 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### relay-url-config.AC1: Relay config screen shown on first launch 18 + - **relay-url-config.AC1.1 Success:** On first launch (no saved relay URL), the relay config screen appears before the welcome screen 19 + - **relay-url-config.AC1.2 Success:** User can accept the pre-filled default URL and proceed to welcome 20 + - **relay-url-config.AC1.3 Success:** User can enter a custom URL and proceed if the relay is healthy 21 + - **relay-url-config.AC1.4 Failure:** User cannot advance past the config screen without a valid, reachable URL 22 + 23 + ### relay-url-config.AC2: Default URL pre-filled 24 + - **relay-url-config.AC2.1 Success:** URL input is pre-filled with `https://relay.ezpds.com` on first launch 25 + 26 + ### relay-url-config.AC5: Returning users skip config screen 27 + - **relay-url-config.AC5.1 Success:** When a relay URL is already in Keychain on launch, the app starts at the welcome step (or home if authenticated) 28 + - **relay-url-config.AC5.2 Edge:** The saved URL is used for relay calls on the same launch it was saved (no restart required) 29 + 30 + ### relay-url-config.AC6: Error and loading states 31 + - **relay-url-config.AC6.1 Success:** A loading/spinner state is shown while the health check is in flight 32 + - **relay-url-config.AC6.2 Failure:** `INVALID_URL` error is shown inline on the config screen (user stays on screen) 33 + - **relay-url-config.AC6.3 Failure:** `UNREACHABLE` error is shown inline on the config screen (user stays on screen) 34 + 35 + --- 36 + 37 + <!-- START_SUBCOMPONENT_A (tasks 1-4) --> 38 + <!-- START_TASK_1 --> 39 + ### Task 1: Add IPC wrappers to `src/lib/ipc.ts` 40 + 41 + **Files:** 42 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` 43 + 44 + **Step 1: Add the `RelayConfigError` type** 45 + 46 + Add this type alongside the other error types in `ipc.ts`. Follow the discriminated union pattern used by the other error types in the file: 47 + 48 + ```typescript 49 + /** Error from relay URL configuration commands. */ 50 + export type RelayConfigError = { code: 'INVALID_URL' | 'UNREACHABLE' | 'KEYCHAIN_ERROR' }; 51 + ``` 52 + 53 + **Step 2: Add the two command wrappers** 54 + 55 + Add these two exports at the bottom of `ipc.ts`, following the same arrow-function style used throughout the file: 56 + 57 + ```typescript 58 + /** 59 + * Returns the saved relay base URL, or null if not yet configured. 60 + * Call this on app mount to decide whether to show the relay config screen. 61 + */ 62 + export const getRelayUrl = (): Promise<string | null> => 63 + invoke('get_relay_url'); 64 + 65 + /** 66 + * Validates url, pings /xrpc/_health, saves to Keychain, and initializes the 67 + * runtime relay client. After this resolves, all relay IPC commands use url. 68 + * Throws RelayConfigError on failure. 69 + */ 70 + export const saveRelayUrl = (url: string): Promise<void> => 71 + invoke('save_relay_url', { url }); 72 + ``` 73 + 74 + **Step 3: Verify TypeScript compiles** 75 + 76 + ```bash 77 + cd apps/identity-wallet && pnpm check 2>&1 | head -30 78 + ``` 79 + 80 + Expected: No type errors. 81 + <!-- END_TASK_1 --> 82 + 83 + <!-- START_TASK_2 --> 84 + ### Task 2: Create `RelayConfigScreen.svelte` 85 + 86 + **Files:** 87 + - Create: `apps/identity-wallet/src/lib/components/onboarding/RelayConfigScreen.svelte` 88 + 89 + Create the file with the following contents: 90 + 91 + ```svelte 92 + <script lang="ts"> 93 + import { saveRelayUrl, type RelayConfigError } from '$lib/ipc'; 94 + 95 + const DEFAULT_RELAY_URL = 'https://relay.ezpds.com'; 96 + 97 + let { onnext }: { onnext: () => void } = $props(); 98 + 99 + let url = $state(DEFAULT_RELAY_URL); 100 + let loading = $state(false); 101 + let error = $state<string | undefined>(undefined); 102 + 103 + let isValidFormat = $derived( 104 + url.trim().length > 0 && 105 + (url.startsWith('http://') || url.startsWith('https://')) 106 + ); 107 + 108 + async function handleConnect() { 109 + error = undefined; 110 + loading = true; 111 + try { 112 + await saveRelayUrl(url.trim()); 113 + onnext(); 114 + } catch (e) { 115 + const relayError = e as RelayConfigError; 116 + if (relayError.code === 'INVALID_URL') { 117 + error = 'Invalid URL — must start with http:// or https://'; 118 + } else if (relayError.code === 'KEYCHAIN_ERROR') { 119 + error = 'Could not save the relay URL. Please try again.'; 120 + } else { 121 + error = 'Could not reach the relay. Check the URL and try again.'; 122 + } 123 + } finally { 124 + loading = false; 125 + } 126 + } 127 + </script> 128 + 129 + <div class="screen"> 130 + <div class="content"> 131 + <h2>Connect to Relay</h2> 132 + <p class="hint"> 133 + Your wallet connects to a relay to create your identity. Use the default 134 + or enter the address of your own relay. 135 + </p> 136 + 137 + <input 138 + type="url" 139 + class:error={!!error} 140 + disabled={loading} 141 + bind:value={url} 142 + placeholder="https://relay.ezpds.com" 143 + autocomplete="off" 144 + autocorrect="off" 145 + autocapitalize="off" 146 + spellcheck={false} 147 + /> 148 + 149 + {#if error} 150 + <p class="error-text">{error}</p> 151 + {/if} 152 + </div> 153 + 154 + <div class="actions"> 155 + {#if loading} 156 + <div class="spinner" role="status" aria-label="Connecting…"></div> 157 + {:else} 158 + <button disabled={!isValidFormat} onclick={handleConnect}>Connect</button> 159 + {/if} 160 + </div> 161 + </div> 162 + 163 + <style> 164 + .screen { 165 + display: flex; 166 + flex-direction: column; 167 + height: 100%; 168 + padding: 2rem; 169 + gap: 1.5rem; 170 + } 171 + 172 + .content { 173 + display: flex; 174 + flex-direction: column; 175 + align-items: center; 176 + flex: 1; 177 + justify-content: center; 178 + gap: 1rem; 179 + } 180 + 181 + h2 { 182 + font-size: 1.5rem; 183 + font-weight: 700; 184 + color: #111827; 185 + margin: 0; 186 + text-align: center; 187 + } 188 + 189 + .hint { 190 + font-size: 0.9rem; 191 + color: #6b7280; 192 + text-align: center; 193 + max-width: 280px; 194 + line-height: 1.4; 195 + margin: 0; 196 + } 197 + 198 + input { 199 + width: 100%; 200 + max-width: 320px; 201 + padding: 1rem; 202 + font-size: 1rem; 203 + border: 2px solid #d1d5db; 204 + border-radius: 12px; 205 + outline: none; 206 + font-family: monospace; 207 + color: #111827; 208 + } 209 + 210 + input:focus { 211 + border-color: #007aff; 212 + } 213 + 214 + input.error { 215 + border-color: #ef4444; 216 + } 217 + 218 + input:disabled { 219 + opacity: 0.6; 220 + } 221 + 222 + .error-text { 223 + font-size: 0.875rem; 224 + color: #ef4444; 225 + margin: 0; 226 + text-align: center; 227 + max-width: 320px; 228 + } 229 + 230 + .actions { 231 + display: flex; 232 + justify-content: center; 233 + padding-bottom: env(safe-area-inset-bottom, 0); 234 + } 235 + 236 + button { 237 + width: 100%; 238 + max-width: 320px; 239 + padding: 1rem; 240 + font-size: 1rem; 241 + font-weight: 600; 242 + background: #007aff; 243 + color: white; 244 + border: none; 245 + border-radius: 12px; 246 + cursor: pointer; 247 + } 248 + 249 + button:disabled { 250 + background: #9ca3af; 251 + cursor: not-allowed; 252 + } 253 + 254 + .spinner { 255 + width: 48px; 256 + height: 48px; 257 + border: 4px solid #e5e7eb; 258 + border-top-color: #007aff; 259 + border-radius: 50%; 260 + animation: spin 0.8s linear infinite; 261 + } 262 + 263 + @keyframes spin { 264 + to { 265 + transform: rotate(360deg); 266 + } 267 + } 268 + </style> 269 + ``` 270 + <!-- END_TASK_2 --> 271 + 272 + <!-- START_TASK_3 --> 273 + ### Task 3: Update `+page.svelte` — step type, mount logic, and renderer 274 + 275 + **Files:** 276 + - Modify: `apps/identity-wallet/src/routes/+page.svelte` 277 + 278 + **Step 1: Import `RelayConfigScreen` and `getRelayUrl`** 279 + 280 + At the top of the `<script>` block, add: 281 + ```typescript 282 + import RelayConfigScreen from '$lib/components/onboarding/RelayConfigScreen.svelte'; 283 + import { getRelayUrl, /* existing imports */ } from '$lib/ipc'; 284 + ``` 285 + 286 + **Step 2: Add `relay_config` to `OnboardingStep`** 287 + 288 + The `OnboardingStep` type union currently starts with `'welcome'`. Add `'relay_config'` at the beginning: 289 + 290 + ```typescript 291 + type OnboardingStep = 292 + | 'relay_config' 293 + | 'welcome' 294 + | 'claim_code' 295 + | 'email' 296 + | 'handle' 297 + | 'password' 298 + | 'loading' 299 + | 'did_ceremony' 300 + | 'did_success' 301 + | 'shamir_backup' 302 + | 'handle_registration' 303 + | 'complete' 304 + | 'authenticating' 305 + | 'home' 306 + | 'did_document' 307 + | 'recovery_info' 308 + | 'auth_failed'; 309 + ``` 310 + 311 + **Step 3: Change the initial step** 312 + 313 + The `step` state variable currently initializes to `'welcome'`. Change it to `'relay_config'`: 314 + 315 + ```typescript 316 + let step = $state<OnboardingStep>('relay_config'); 317 + ``` 318 + 319 + **Step 4: Add relay URL check to `onMount`** 320 + 321 + Update the existing `onMount` block. The current `onMount` only registers the `auth_ready` listener. Add the relay URL check at the start: 322 + 323 + ```typescript 324 + onMount(async () => { 325 + // If the user has already configured a relay URL, skip the config screen. 326 + const savedUrl = await getRelayUrl(); 327 + if (savedUrl) { 328 + step = 'welcome'; 329 + } 330 + 331 + // Existing: listen for auth_ready deep-link callback from the OAuth flow. 332 + listen('auth_ready', () => { 333 + goTo('home'); 334 + }); 335 + }); 336 + ``` 337 + 338 + > The initial `step = 'relay_config'` means a first-launch user sees the config screen immediately. The `getRelayUrl()` IPC call is a fast Keychain read (~milliseconds), so returning users are redirected to `welcome` before the screen renders visibly. 339 + 340 + **Step 5: Add the `relay_config` rendering block** 341 + 342 + In the template section, add the `relay_config` case as the FIRST `{#if}` branch (before `step === 'welcome'`): 343 + 344 + ```svelte 345 + {#if step === 'relay_config'} 346 + <RelayConfigScreen onnext={() => goTo('welcome')} /> 347 + {:else if step === 'welcome'} 348 + <!-- existing welcome block unchanged --> 349 + ``` 350 + 351 + **Step 6: Verify TypeScript** 352 + 353 + ```bash 354 + cd apps/identity-wallet && pnpm check 2>&1 | head -30 355 + ``` 356 + 357 + Expected: No type errors. 358 + <!-- END_TASK_3 --> 359 + 360 + <!-- START_TASK_4 --> 361 + ### Task 4: Run the app and commit 362 + 363 + **Step 1: Build the frontend** 364 + 365 + ```bash 366 + cd apps/identity-wallet && pnpm build 2>&1 | tail -20 367 + ``` 368 + 369 + Expected: Builds without errors or warnings. 370 + 371 + **Step 2: Verify TypeScript and Svelte** 372 + 373 + ```bash 374 + cd apps/identity-wallet && pnpm check 2>&1 375 + ``` 376 + 377 + Expected: No errors. 378 + 379 + **Step 3: Smoke test in Simulator (manual)** 380 + 381 + Launch the app in the iOS Simulator: 382 + ```bash 383 + cd apps/identity-wallet && cargo tauri ios dev 384 + ``` 385 + 386 + **Verify these behaviors manually:** 387 + 388 + | Scenario | Expected | 389 + |----------|----------| 390 + | Fresh state (no saved URL) | Relay config screen appears first with `https://relay.ezpds.com` pre-filled | 391 + | Enter invalid URL (e.g. `notaurl`) and tap Connect | Inline error: "Invalid URL — must start with http:// or https://" | 392 + | Enter unreachable URL (e.g. `https://does-not-exist.example.com`) and tap Connect | Loading spinner appears, then inline error: "Could not reach the relay…" | 393 + | Enter correct relay URL (or accept default with relay running) and tap Connect | Loading spinner, then advances to Welcome screen | 394 + | Restart the app after saving URL | Relay config screen is NOT shown; Welcome screen appears directly | 395 + 396 + **Step 4: Update `apps/identity-wallet/CLAUDE.md`** 397 + 398 + The implementation plan's CLAUDE.md documents the app's key contracts and IPC commands. Update it to reflect the changes made across all four phases: 399 + 400 + - Add `relay-base-url` to the Keychain accounts section (new account key added in Phase 3) 401 + - Add `get_relay_url` and `save_relay_url` to the IPC commands list 402 + - Note that `relay_client` is now runtime-configurable via `AppState::set_relay_client()` rather than a compile-time global static 403 + 404 + **Step 5: Commit** 405 + 406 + ```bash 407 + git add apps/identity-wallet/src/lib/ipc.ts \ 408 + apps/identity-wallet/src/lib/components/onboarding/RelayConfigScreen.svelte \ 409 + apps/identity-wallet/src/routes/+page.svelte \ 410 + apps/identity-wallet/CLAUDE.md 411 + git commit -m "feat: add relay URL configuration screen to onboarding" 412 + ``` 413 + <!-- END_TASK_4 --> 414 + <!-- END_SUBCOMPONENT_A -->
+209
docs/implementation-plans/2026-03-27-relay-url-config/test-requirements.md
··· 1 + # relay-url-config: Test Requirements 2 + 3 + **Feature:** relay-url-config -- Relay URL Configuration 4 + **Design plan:** `docs/design-plans/2026-03-27-relay-url-config.md` 5 + **Last verified:** 2026-03-27 6 + 7 + --- 8 + 9 + ## Acceptance Criteria Index 10 + 11 + Every acceptance criterion from the design plan, mapped to its implementing phase and test strategy. 12 + 13 + ### relay-url-config.AC1: Relay config screen shown on first launch 14 + 15 + | ID | Criterion | Phase | Test Strategy | 16 + |----|-----------|-------|---------------| 17 + | relay-url-config.AC1.1 | On first launch (no saved relay URL), the relay config screen appears before the welcome screen | Phase 4 | Human Verification | 18 + | relay-url-config.AC1.2 | User can accept the pre-filled default URL and proceed to welcome | Phase 4 | Human Verification | 19 + | relay-url-config.AC1.3 | User can enter a custom URL and proceed if the relay is healthy | Phase 4 | Human Verification | 20 + | relay-url-config.AC1.4 | User cannot advance past the config screen without a valid, reachable URL | Phase 4 | Human Verification | 21 + 22 + ### relay-url-config.AC2: Default URL pre-filled 23 + 24 + | ID | Criterion | Phase | Test Strategy | 25 + |----|-----------|-------|---------------| 26 + | relay-url-config.AC2.1 | URL input is pre-filled with `https://relay.ezpds.com` on first launch | Phase 4 | Human Verification | 27 + 28 + ### relay-url-config.AC3: URL persists across restarts 29 + 30 + | ID | Criterion | Phase | Test Strategy | 31 + |----|-----------|-------|---------------| 32 + | relay-url-config.AC3.1 | After saving a URL and relaunching the app, the relay config screen is not shown | Phase 3, 4 | Human Verification | 33 + | relay-url-config.AC3.2 | All relay IPC commands on subsequent launches use the saved URL | Phase 3 | Automated Test Coverage Required | 34 + 35 + ### relay-url-config.AC4: Relay reachability verified before saving 36 + 37 + | ID | Criterion | Phase | Test Strategy | 38 + |----|-----------|-------|---------------| 39 + | relay-url-config.AC4.1 | A URL whose `/xrpc/_health` returns HTTP 200 is accepted | Phase 3 | Human Verification | 40 + | relay-url-config.AC4.2 | An unreachable host surfaces an `UNREACHABLE` inline error | Phase 3, 4 | Human Verification | 41 + | relay-url-config.AC4.3 | A malformed URL (not `http`/`https`, empty host) surfaces an `INVALID_URL` error before any network call | Phase 3 | Automated Test Coverage Required | 42 + | relay-url-config.AC4.4 | A URL with a trailing slash is accepted and normalized (slash stripped) before saving | Phase 3 | Automated Test Coverage Required | 43 + 44 + ### relay-url-config.AC5: Returning users skip config screen 45 + 46 + | ID | Criterion | Phase | Test Strategy | 47 + |----|-----------|-------|---------------| 48 + | relay-url-config.AC5.1 | When a relay URL is already in Keychain on launch, the app starts at the welcome step (or home if authenticated) | Phase 3, 4 | Human Verification | 49 + | relay-url-config.AC5.2 | The saved URL is used for relay calls on the same launch it was saved (no restart required) | Phase 3 | Human Verification | 50 + 51 + ### relay-url-config.AC6: Error and loading states 52 + 53 + | ID | Criterion | Phase | Test Strategy | 54 + |----|-----------|-------|---------------| 55 + | relay-url-config.AC6.1 | A loading/spinner state is shown while the health check is in flight | Phase 4 | Human Verification | 56 + | relay-url-config.AC6.2 | `INVALID_URL` error is shown inline on the config screen (user stays on screen) | Phase 4 | Human Verification | 57 + | relay-url-config.AC6.3 | `UNREACHABLE` error is shown inline on the config screen (user stays on screen) | Phase 4 | Human Verification | 58 + 59 + --- 60 + 61 + ## Automated Test Coverage Required 62 + 63 + Tests are in `apps/identity-wallet/src-tauri/src/lib.rs` (Phase 3, Task 3). All automated criteria target Rust unit tests exercising the `normalize_relay_url` helper and the `keychain::store_relay_url` / `keychain::load_relay_url` round-trip. 64 + 65 + | Criterion | Test File | Test Function | Verifies | 66 + |-----------|-----------|---------------|----------| 67 + | relay-url-config.AC4.3 | `lib.rs` | `normalize_relay_url_rejects_non_http_schemes` | `ftp://` and `ws://` URLs return `RelayConfigError::InvalidUrl` | 68 + | relay-url-config.AC4.3 | `lib.rs` | `normalize_relay_url_rejects_malformed_input` | Empty string and non-URL string return `RelayConfigError::InvalidUrl` | 69 + | relay-url-config.AC4.3 | `lib.rs` | `normalize_relay_url_accepts_http_and_https` | `https://` and `http://` URLs are accepted without error | 70 + | relay-url-config.AC4.4 | `lib.rs` | `normalize_relay_url_strips_trailing_slash` | `https://relay.example.com/` becomes `https://relay.example.com` | 71 + | relay-url-config.AC3.2 | `lib.rs` | `relay_url_round_trips_through_keychain` | A URL stored via `store_relay_url` is retrieved unchanged by `load_relay_url` | 72 + | relay-url-config.AC3.2 | `lib.rs` | `get_relay_url_returns_none_before_save` | `get_relay_url()` returns `None` when no URL has been saved to Keychain | 73 + 74 + --- 75 + 76 + ## Human Verification Required 77 + 78 + All UI component criteria (Phase 4) and integration behaviors that require a live relay (Phase 3 health check, Phase 4 screen navigation) require human verification on the iOS Simulator. This project has no browser-based component test harness; Svelte components render inside a Tauri WKWebView on iOS, so DOM-level testing frameworks are not available. The `save_relay_url` command makes live HTTP calls and is not unit-tested. 79 + 80 + ### Phase 3: IPC Commands (live relay required) 81 + 82 + | Criterion | Why Manual | Steps | 83 + |-----------|------------|-------| 84 + | relay-url-config.AC4.1 | Health check requires a live relay returning HTTP 200 | 1. Start a local relay (`cargo run -p relay`). 2. In the iOS Simulator, enter the local relay URL (e.g., `http://localhost:2583`) on the config screen. 3. Tap Connect. 4. Verify the spinner appears, then the app advances to the welcome screen. | 85 + | relay-url-config.AC4.2 | Unreachable host requires network-level failure, not mockable in unit tests | 1. On the relay config screen, enter `https://does-not-exist.example.com`. 2. Tap Connect. 3. Verify the spinner appears, then an inline error reads "Could not reach the relay. Check the URL and try again." 4. Verify you remain on the config screen. | 86 + | relay-url-config.AC5.2 | Requires verifying runtime state across IPC commands within a single app session | 1. On the config screen, enter a valid relay URL and tap Connect. 2. Proceed through onboarding (claim code, email, handle, password). 3. Verify that account creation succeeds (it uses the relay URL saved moments ago, not the compile-time default). | 87 + 88 + ### Phase 4: Frontend Relay Configuration Screen 89 + 90 + | Criterion | Why Manual | Steps | 91 + |-----------|------------|-------| 92 + | relay-url-config.AC1.1 | Navigation gating requires iOS Simulator observation | 1. Reset the iOS Simulator (Erase All Content and Settings). 2. Launch the app via `cargo tauri ios dev`. 3. Verify the first screen shown is the relay configuration screen (header says "Connect to Relay"), not the welcome screen. | 93 + | relay-url-config.AC1.2 | Requires tapping through the default pre-filled URL | 1. On the relay config screen (fresh state), do not modify the URL. 2. Verify the input field shows `https://relay.ezpds.com`. 3. Tap Connect. 4. If the production relay is reachable, verify the app advances to the welcome screen. | 94 + | relay-url-config.AC1.3 | Requires entering a custom URL and verifying navigation | 1. On the relay config screen, clear the input field. 2. Enter a custom URL pointing to a running relay (e.g., `http://localhost:2583`). 3. Tap Connect. 4. Verify the app advances to the welcome screen. | 95 + | relay-url-config.AC1.4 | Requires confirming the screen blocks advancement on error | 1. On the relay config screen, enter `https://does-not-exist.example.com`. 2. Tap Connect. 3. Verify an error message appears and you remain on the config screen. 4. Clear the field, enter `notaurl`, and tap Connect. 5. Verify an error message appears and you remain on the config screen. | 96 + | relay-url-config.AC2.1 | Visual inspection of the pre-filled input value | 1. Reset the iOS Simulator. 2. Launch the app. 3. On the relay config screen, verify the URL text input contains exactly `https://relay.ezpds.com`. | 97 + | relay-url-config.AC3.1 | Requires app relaunch to verify Keychain persistence | 1. On the relay config screen, accept the default URL (or enter a valid one) and tap Connect. 2. Force-quit the app completely. 3. Relaunch the app. 4. Verify the relay config screen does NOT appear; the app starts at the welcome screen (or home if previously authenticated). | 98 + | relay-url-config.AC5.1 | Requires app relaunch with pre-existing Keychain state | 1. Complete the relay configuration step so a URL is saved. 2. Force-quit and relaunch the app. 3. Verify the app starts at the welcome screen (or home if OAuth tokens exist). The relay config screen is skipped. | 99 + | relay-url-config.AC6.1 | Visual rendering of loading state | 1. On the relay config screen, enter a valid relay URL. 2. Tap Connect. 3. Observe that the Connect button is replaced by a spinning indicator while the health check is in flight. 4. Verify the input field is disabled during loading. | 100 + | relay-url-config.AC6.2 | Visual rendering of inline error | 1. On the relay config screen, enter `notaurl`. 2. Tap Connect. 3. Verify an inline error message appears below the input field reading "Invalid URL -- must start with http:// or https://". 4. Verify the input field border turns red. 5. Verify you remain on the config screen. | 101 + | relay-url-config.AC6.3 | Visual rendering of inline error | 1. On the relay config screen, enter `https://does-not-exist.example.com`. 2. Tap Connect. 3. Verify an inline error message appears reading "Could not reach the relay. Check the URL and try again." 4. Verify you remain on the config screen. | 102 + 103 + --- 104 + 105 + ## End-to-End Scenarios 106 + 107 + ### E2E-1: First launch -- configure relay and begin onboarding 108 + 109 + **Purpose:** Validates the complete first-launch path from relay configuration through the start of onboarding, exercising Phases 3-4. 110 + 111 + | Step | Action | Expected | 112 + |------|--------|----------| 113 + | 1 | Reset the iOS Simulator (Erase All Content and Settings) | Simulator is clean | 114 + | 2 | Launch the app via `cargo tauri ios dev` | Relay config screen appears with "Connect to Relay" header | 115 + | 3 | Verify the URL input is pre-filled | Input contains `https://relay.ezpds.com` | 116 + | 4 | Tap Connect (with a reachable relay) | Spinner appears, then app advances to the welcome screen | 117 + | 5 | Verify the welcome screen is functional | "Get Started" button is visible and tappable | 118 + 119 + ### E2E-2: First launch -- invalid URL then recovery 120 + 121 + **Purpose:** Validates error handling and recovery on the relay config screen, exercising Phases 3-4. 122 + 123 + | Step | Action | Expected | 124 + |------|--------|----------| 125 + | 1 | Reset the iOS Simulator | Simulator is clean | 126 + | 2 | Launch the app | Relay config screen appears | 127 + | 3 | Clear the URL field and type `notaurl` | Connect button is disabled (URL does not start with http/https) | 128 + | 4 | Clear the field and type `https://does-not-exist.example.com` | Connect button is enabled | 129 + | 5 | Tap Connect | Spinner appears, then inline error: "Could not reach the relay..." | 130 + | 6 | Clear the field and enter a valid relay URL (e.g., `https://relay.ezpds.com` or `http://localhost:2583`) | Connect button is enabled, error text clears on next tap | 131 + | 7 | Tap Connect | Spinner appears, then app advances to the welcome screen | 132 + 133 + ### E2E-3: Returning user -- relay config screen skipped 134 + 135 + **Purpose:** Validates that returning users bypass the relay configuration screen entirely, exercising the Keychain persistence path from Phase 3 and the mount-time check from Phase 4. 136 + 137 + | Step | Action | Expected | 138 + |------|--------|----------| 139 + | 1 | Start from a state where the relay URL has been saved (E2E-1 completed) | App is on the welcome screen or further | 140 + | 2 | Force-quit the app completely | App is terminated | 141 + | 3 | Relaunch the app | App opens directly to the welcome screen (not the relay config screen) | 142 + | 4 | If OAuth tokens also exist in Keychain, verify the app opens to the home screen instead | Home screen with identity card is displayed | 143 + 144 + ### E2E-4: Full journey -- relay config through account creation 145 + 146 + **Purpose:** Validates that the relay URL configured on the config screen is used for all subsequent IPC commands (account creation, DID ceremony, handle registration), exercising all four phases. 147 + 148 + | Step | Action | Expected | 149 + |------|--------|----------| 150 + | 1 | Reset the iOS Simulator | Simulator is clean | 151 + | 2 | Launch the app | Relay config screen appears | 152 + | 3 | Enter a valid relay URL and tap Connect | App advances to the welcome screen | 153 + | 4 | Proceed through full onboarding (claim code, email, handle, password, DID ceremony, Shamir backup, handle registration) | Each step completes successfully using the saved relay URL | 154 + | 5 | After the `complete` step, verify the home screen | Identity card shows handle, DID, email; relay status is "Connected" | 155 + | 6 | Force-quit and relaunch | App opens to home screen (both relay URL and OAuth tokens are restored from Keychain) | 156 + 157 + ### E2E-5: Custom relay URL -- self-hosted deployment 158 + 159 + **Purpose:** Validates that a non-default relay URL works end-to-end for self-hosted deployments. 160 + 161 + | Step | Action | Expected | 162 + |------|--------|----------| 163 + | 1 | Start a local relay instance (`cargo run -p relay`) | Relay is listening on `http://localhost:2583` | 164 + | 2 | Reset the iOS Simulator and launch the app | Relay config screen appears | 165 + | 3 | Clear the default URL, enter `http://localhost:2583`, and tap Connect | Spinner, then app advances to the welcome screen | 166 + | 4 | Proceed through onboarding | All commands succeed against the local relay | 167 + | 5 | Force-quit and relaunch | App opens to home; relay status shows "Connected" (to the local relay) | 168 + 169 + --- 170 + 171 + ## Traceability Matrix 172 + 173 + Every acceptance criterion mapped to its automated test and/or manual verification step. 174 + 175 + | Acceptance Criterion | Automated Test | Manual Step | 176 + |----------------------|----------------|-------------| 177 + | relay-url-config.AC1.1 | -- | Phase 4: verify config screen appears first on fresh launch | 178 + | relay-url-config.AC1.2 | -- | Phase 4: accept default URL, verify advancement to welcome | 179 + | relay-url-config.AC1.3 | -- | Phase 4: enter custom URL, verify advancement to welcome | 180 + | relay-url-config.AC1.4 | -- | Phase 4: enter invalid/unreachable URL, verify screen blocks advancement | 181 + | relay-url-config.AC2.1 | -- | Phase 4: verify input pre-filled with `https://relay.ezpds.com` | 182 + | relay-url-config.AC3.1 | -- | Phase 4: save URL, force-quit, relaunch, verify config screen is skipped | 183 + | relay-url-config.AC3.2 | `relay_url_round_trips_through_keychain` + `get_relay_url_returns_none_before_save` | -- | 184 + | relay-url-config.AC4.1 | -- | Phase 3: connect to a live relay, verify acceptance | 185 + | relay-url-config.AC4.2 | -- | Phase 3/4: enter unreachable URL, verify inline error | 186 + | relay-url-config.AC4.3 | `normalize_relay_url_rejects_non_http_schemes` + `normalize_relay_url_rejects_malformed_input` + `normalize_relay_url_accepts_http_and_https` | -- | 187 + | relay-url-config.AC4.4 | `normalize_relay_url_strips_trailing_slash` | -- | 188 + | relay-url-config.AC5.1 | -- | Phase 4: relaunch with saved URL, verify config screen skipped | 189 + | relay-url-config.AC5.2 | -- | Phase 3: save URL, then proceed through onboarding in same session | 190 + | relay-url-config.AC6.1 | -- | Phase 4: verify spinner during health check | 191 + | relay-url-config.AC6.2 | -- | Phase 4: verify `INVALID_URL` inline error display | 192 + | relay-url-config.AC6.3 | -- | Phase 4: verify `UNREACHABLE` inline error display | 193 + 194 + --- 195 + 196 + ## Summary 197 + 198 + - **Total acceptance criteria:** 16 199 + - **Automated test coverage:** 3 criteria (AC3.2, AC4.3, AC4.4 -- Rust unit tests in `lib.rs`) 200 + - **Human verification required:** 13 criteria (UI rendering, navigation gating, live relay health check, Keychain persistence across restarts) 201 + - **End-to-end scenarios:** 5 202 + 203 + ### Prerequisites for Human Verification 204 + 205 + - macOS with Xcode installed 206 + - iOS Simulator available (iPhone target) 207 + - `cargo tauri ios dev` running successfully 208 + - A relay instance accessible from the simulator (local via `cargo run -p relay` or remote production) 209 + - `cargo test -p identity-wallet` passing (all Phase 3 automated tests green)