···11+# MM-150 Implementation Plan — Phase 4: State machine wiring
22+33+**Goal:** Connect all three home screens into the `+page.svelte` flat state machine.
44+55+**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.
66+77+**Tech Stack:** Svelte 5, TypeScript
88+99+**Scope:** Phase 4 of 6
1010+1111+**Codebase verified:** 2026-03-27
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+### MM-150.AC3: Three action flows work
1818+- **MM-150.AC3.4 Success:** Tapping View DID Document navigates to `did_document` step
1919+- **MM-150.AC3.9 Success:** Back from DID document returns to home
2020+- **MM-150.AC3.10 Success:** Tapping Recovery Info navigates to `recovery_info` step
2121+- **MM-150.AC3.14 Success:** Back from recovery info returns to home
2222+2323+### MM-150.AC5: App launches to home when already onboarded
2424+- **MM-150.AC5.1 Success:** App starts at the `home` step when OAuth tokens exist in Keychain on launch
2525+- **MM-150.AC5.2 Success:** `homeData` is loaded on mount of `HomeScreen` regardless of entry path
2626+2727+---
2828+2929+<!-- START_SUBCOMPONENT_A (tasks 0-1) -->
3030+<!-- START_TASK_0 -->
3131+### Task 0: Create stub components for `DIDDocumentScreen` and `RecoveryInfoScreen`
3232+3333+**Verifies:** (prerequisite — allows Phase 4 imports to resolve before Phases 5 and 6 are executed)
3434+3535+**Files:**
3636+- Create: `apps/identity-wallet/src/lib/components/home/DIDDocumentScreen.svelte` (stub)
3737+- Create: `apps/identity-wallet/src/lib/components/home/RecoveryInfoScreen.svelte` (stub)
3838+3939+**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.
4040+4141+Create `apps/identity-wallet/src/lib/components/home/DIDDocumentScreen.svelte`:
4242+4343+```svelte
4444+<script lang="ts">
4545+ let {
4646+ didDoc,
4747+ onback,
4848+ }: {
4949+ didDoc: Record<string, unknown>;
5050+ onback: () => void;
5151+ } = $props();
5252+</script>
5353+<div>DIDDocumentScreen stub — replaced by Phase 5</div>
5454+```
5555+5656+Create `apps/identity-wallet/src/lib/components/home/RecoveryInfoScreen.svelte`:
5757+5858+```svelte
5959+<script lang="ts">
6060+ let {
6161+ share1InKeychain,
6262+ onback,
6363+ }: {
6464+ share1InKeychain: boolean;
6565+ onback: () => void;
6666+ } = $props();
6767+</script>
6868+<div>RecoveryInfoScreen stub — replaced by Phase 6</div>
6969+```
7070+7171+**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.
7272+7373+**Verification:**
7474+Run from `apps/identity-wallet/`: `pnpm check`
7575+Expected: No TypeScript errors from the stub components themselves
7676+7777+**Commit:** (defer — commit alongside the `+page.svelte` changes in Task 1)
7878+<!-- END_TASK_0 -->
7979+8080+<!-- START_TASK_1 -->
8181+### Task 1: Extend OnboardingStep union and wire home screens in `+page.svelte`
8282+8383+**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
8484+8585+**Files:**
8686+- Modify: `apps/identity-wallet/src/routes/+page.svelte`
8787+8888+**Current state of `+page.svelte` (verified 2026-03-27):**
8989+9090+- `OnboardingStep` union defined at lines 26–39; currently ends with `'authenticated'` and `'auth_failed'`
9191+- The `auth_ready` event listener at line 66 calls `goTo('authenticated')`
9292+- The `authenticated` stub at lines 207–212 is a simple `<div>` placeholder
9393+- There is no `home`, `did_document`, or `recovery_info` step
9494+9595+**Changes required (in order):**
9696+9797+**1. Add new imports at the top of the `<script>` block**
9898+9999+After the existing Svelte/IPC imports (after line 14), add:
100100+101101+```typescript
102102+ import HomeScreen from '$lib/components/home/HomeScreen.svelte';
103103+ import DIDDocumentScreen from '$lib/components/home/DIDDocumentScreen.svelte';
104104+ import RecoveryInfoScreen from '$lib/components/home/RecoveryInfoScreen.svelte';
105105+ import { type HomeData } from '$lib/ipc';
106106+```
107107+108108+**2. Replace `OnboardingStep` union definition**
109109+110110+Replace the current union (lines 26–39):
111111+112112+```typescript
113113+ type OnboardingStep =
114114+ | 'welcome'
115115+ | 'claim_code'
116116+ | 'email'
117117+ | 'handle'
118118+ | 'password'
119119+ | 'loading'
120120+ | 'did_ceremony'
121121+ | 'did_success'
122122+ | 'shamir_backup'
123123+ | 'complete'
124124+ | 'authenticating'
125125+ | 'authenticated'
126126+ | 'auth_failed';
127127+```
128128+129129+With:
130130+131131+```typescript
132132+ type OnboardingStep =
133133+ | 'welcome'
134134+ | 'claim_code'
135135+ | 'email'
136136+ | 'handle'
137137+ | 'password'
138138+ | 'loading'
139139+ | 'did_ceremony'
140140+ | 'did_success'
141141+ | 'shamir_backup'
142142+ | 'complete'
143143+ | 'authenticating'
144144+ | 'home'
145145+ | 'did_document'
146146+ | 'recovery_info'
147147+ | 'auth_failed';
148148+```
149149+150150+**3. Add `homeData` state variable**
151151+152152+After the `let authError` state declaration (after line 54), add:
153153+154154+```typescript
155155+ let homeData = $state<HomeData | null>(null);
156156+```
157157+158158+**4. Update auth_ready listener**
159159+160160+In `onMount` (line 66), change `goTo('authenticated')` to `goTo('home')`:
161161+162162+```typescript
163163+ listen('auth_ready', () => {
164164+ goTo('home');
165165+ });
166166+```
167167+168168+**5. Replace the `authenticated` stub block with the three home screens**
169169+170170+Replace the current `{:else if step === 'authenticated'}` block (lines 207–212):
171171+172172+```svelte
173173+ {:else if step === 'authenticated'}
174174+ <div class="oauth-screen">
175175+ <div class="oauth-icon" aria-hidden="true">✓</div>
176176+ <h2 class="oauth-title">Authenticated</h2>
177177+ <p class="oauth-body">Your identity wallet is ready.</p>
178178+ </div>
179179+```
180180+181181+With three new blocks (place them in the same position in the `{#if}` chain):
182182+183183+```svelte
184184+ {:else if step === 'home'}
185185+ <HomeScreen
186186+ onnavdiddoc={() => goTo('did_document')}
187187+ onnavrecovery={() => goTo('recovery_info')}
188188+ onlogout={() => goTo('welcome')}
189189+ />
190190+191191+ {:else if step === 'did_document'}
192192+ <DIDDocumentScreen
193193+ didDoc={homeData?.session?.didDoc ?? {}}
194194+ onback={() => goTo('home')}
195195+ />
196196+197197+ {:else if step === 'recovery_info'}
198198+ <RecoveryInfoScreen
199199+ share1InKeychain={homeData?.share1InKeychain ?? false}
200200+ onback={() => goTo('home')}
201201+ />
202202+```
203203+204204+**6. Update the `AuthenticatingScreen` `onresolved` callback**
205205+206206+The `AuthenticatingScreen` component (within `{:else if step === 'authenticating'}`) uses `onresolved={() => goTo('authenticated')}`. Since `'authenticated'` has been removed from `OnboardingStep`, this must also be updated.
207207+208208+Find:
209209+```svelte
210210+{:else if step === 'authenticating'}
211211+ <AuthenticatingScreen onresolved={() => goTo('authenticated')} />
212212+```
213213+214214+Change to:
215215+```svelte
216216+{:else if step === 'authenticating'}
217217+ <AuthenticatingScreen onresolved={() => goTo('home')} />
218218+```
219219+220220+---
221221+222222+**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.
223223+224224+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:
225225+226226+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:
227227+228228+Update `HomeScreen.svelte`'s props to:
229229+230230+```typescript
231231+ let {
232232+ onnavdiddoc,
233233+ onnavrecovery,
234234+ onlogout,
235235+ }: {
236236+ onnavdiddoc: (data: HomeData) => void;
237237+ onnavrecovery: (data: HomeData) => void;
238238+ onlogout: () => void;
239239+ } = $props();
240240+```
241241+242242+And update the nav button handlers in `HomeScreen.svelte`:
243243+244244+```svelte
245245+ <button class="action-btn" onclick={() => onnavdiddoc(homeData!)}>
246246+ View DID Document
247247+ </button>
248248+ ...
249249+ <button class="action-btn" onclick={() => onnavrecovery(homeData!)}>
250250+ Recovery Info
251251+ </button>
252252+```
253253+254254+Then in `+page.svelte`, update the three home screen blocks:
255255+256256+```svelte
257257+ {:else if step === 'home'}
258258+ <HomeScreen
259259+ onnavdiddoc={(data) => { homeData = data; goTo('did_document'); }}
260260+ onnavrecovery={(data) => { homeData = data; goTo('recovery_info'); }}
261261+ onlogout={() => goTo('welcome')}
262262+ />
263263+264264+ {:else if step === 'did_document'}
265265+ <DIDDocumentScreen
266266+ didDoc={homeData?.session?.didDoc ?? {}}
267267+ onback={() => goTo('home')}
268268+ />
269269+270270+ {:else if step === 'recovery_info'}
271271+ <RecoveryInfoScreen
272272+ share1InKeychain={homeData?.share1InKeychain ?? false}
273273+ onback={() => goTo('home')}
274274+ />
275275+```
276276+277277+**Summary of all edits to `+page.svelte`:**
278278+1. Add 4 import lines after existing imports
279279+2. Extend `OnboardingStep` union (add `'home'`, `'did_document'`, `'recovery_info'`; remove `'authenticated'`)
280280+3. Add `let homeData = $state<HomeData | null>(null);` after `authError` state
281281+4. Change `goTo('authenticated')` → `goTo('home')` in auth_ready listener
282282+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
283283+6. Change `AuthenticatingScreen onresolved={() => goTo('authenticated')}` → `onresolved={() => goTo('home')}`
284284+285285+**Also update `HomeScreen.svelte` (from Phase 3):**
286286+- Change `onnavdiddoc: () => void` → `onnavdiddoc: (data: HomeData) => void`
287287+- Change `onnavrecovery: () => void` → `onnavrecovery: (data: HomeData) => void`
288288+- Add `import { loadHomeData, logOut, type HomeData } from '$lib/ipc';` (it likely already imports these — confirm)
289289+- Update button onclick handlers to pass `homeData!`
290290+291291+**Verification:**
292292+Run from `apps/identity-wallet/`: `pnpm check`
293293+Expected: No TypeScript errors
294294+295295+Run: `cargo tauri ios dev` (requires Xcode + iOS Simulator)
296296+Expected:
297297+- App starts at welcome screen (no tokens)
298298+- After full onboarding + OAuth flow, app navigates to home screen showing identity card
299299+- `auth_ready` event (simulated by relaunching with tokens in Keychain) navigates to home screen
300300+- "View DID Document" button only appears when `homeData.session.didDoc` is non-null
301301+- Back buttons return to home
302302+303303+**Commit:**
304304+```bash
305305+git add apps/identity-wallet/src/routes/+page.svelte \
306306+ apps/identity-wallet/src/lib/components/home/HomeScreen.svelte
307307+git commit -m "feat: wire home, did_document, and recovery_info steps into OnboardingStep state machine"
308308+```
309309+<!-- END_TASK_1 -->
310310+<!-- END_SUBCOMPONENT_A -->
···11+# MM-150: Test Requirements
22+33+**Ticket:** MM-150 — Wallet Home Screen: Identity Overview + Session Status
44+**Design plan:** `docs/design-plans/2026-03-27-MM-150.md`
55+**Last verified:** 2026-03-27
66+77+---
88+99+## Acceptance Criteria Index
1010+1111+Every acceptance criterion from the design plan, mapped to its implementing phase and test strategy.
1212+1313+### MM-150.AC1: Identity card displays correctly
1414+1515+| ID | Criterion | Phase | Test Strategy |
1616+|----|-----------|-------|---------------|
1717+| MM-150.AC1.1 | Home screen shows the user's handle from `getSession` response | Phase 3 | Human Verification |
1818+| 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 |
1919+| MM-150.AC1.3 | Copy button copies the full untruncated DID to clipboard | Phase 3 | Human Verification |
2020+| MM-150.AC1.4 | Email from `getSession` is shown | Phase 3 | Human Verification |
2121+| MM-150.AC1.5 | DID-derived avatar circle is visible with a stable hue derived from the DID hash | Phase 2 | Human Verification |
2222+| MM-150.AC1.6 | Avatar shows the first letter of the handle as its initial | Phase 2 | Human Verification |
2323+| MM-150.AC1.7 | Avatar shows `?` when handle is `handle.invalid` | Phase 2 | Human Verification |
2424+| MM-150.AC1.8 | Loading spinner is shown while `loadHomeData()` is in flight | Phase 3 | Human Verification |
2525+2626+### MM-150.AC2: Status indicators are accurate
2727+2828+| ID | Criterion | Phase | Test Strategy |
2929+|----|-----------|-------|---------------|
3030+| MM-150.AC2.1 | Relay status shows Connected when `_health` returns 200 | Phase 1 | Automated Test Coverage Required |
3131+| MM-150.AC2.2 | Relay status shows Error when `_health` returns non-200 or network fails | Phase 1 | Automated Test Coverage Required |
3232+| MM-150.AC2.3 | Session status shows Active when `getSession` succeeds | Phase 1 | Automated Test Coverage Required |
3333+| MM-150.AC2.4 | Session status shows Error when `getSession` fails after OAuthClient refresh attempt | Phase 1 | Automated Test Coverage Required |
3434+| MM-150.AC2.5 | Relay and session statuses are independent (one can be error while other is active) | Phase 1 | Automated Test Coverage Required |
3535+3636+### MM-150.AC3: Three action flows work
3737+3838+| ID | Criterion | Phase | Test Strategy |
3939+|----|-----------|-------|---------------|
4040+| MM-150.AC3.1 | Log out clears `oauth-access-token`, `oauth-refresh-token`, and `did` from Keychain | Phase 1 | Automated Test Coverage Required |
4141+| MM-150.AC3.2 | Log out navigates to the welcome screen | Phase 3, 4 | Human Verification |
4242+| MM-150.AC3.3 | Device key and DPoP key remain in Keychain after logout | Phase 1 | Automated Test Coverage Required |
4343+| MM-150.AC3.4 | Tapping View DID Document navigates to `did_document` step | Phase 4 | Human Verification |
4444+| MM-150.AC3.5 | DID document view shows `id`, `alsoKnownAs`, `verificationMethod`, and `service` fields | Phase 5 | Human Verification |
4545+| MM-150.AC3.6 | Raw JSON toggle reveals the full DID document as a monospace block | Phase 5 | Human Verification |
4646+| MM-150.AC3.7 | Key copy button copies `publicKeyMultibase` value to clipboard | Phase 5 | Human Verification |
4747+| MM-150.AC3.8 | View DID Document button is hidden when `session.didDoc` is null | Phase 3 | Human Verification |
4848+| MM-150.AC3.9 | Back from DID document returns to home | Phase 4, 5 | Human Verification |
4949+| MM-150.AC3.10 | Tapping Recovery Info navigates to `recovery_info` step | Phase 4 | Human Verification |
5050+| MM-150.AC3.11 | Share 1 shows checkmark when `recovery-share-1` exists in Keychain | Phase 6 | Human Verification |
5151+| MM-150.AC3.12 | Share 1 shows X when `recovery-share-1` is absent from Keychain | Phase 6 | Human Verification |
5252+| MM-150.AC3.13 | Share 2 always shows checkmark (static relay custody fact) | Phase 6 | Human Verification |
5353+| MM-150.AC3.14 | Back from recovery info returns to home | Phase 4, 6 | Human Verification |
5454+5555+### MM-150.AC4: Tauri commands and IPC wrappers
5656+5757+| ID | Criterion | Phase | Test Strategy |
5858+|----|-----------|-------|---------------|
5959+| MM-150.AC4.1 | `load_home_data` returns `relayHealthy: true` when `_health` returns 200 | Phase 1 | Automated Test Coverage Required |
6060+| MM-150.AC4.2 | `load_home_data` returns populated `session` when `getSession` succeeds | Phase 1 | Automated Test Coverage Required |
6161+| MM-150.AC4.3 | `load_home_data` returns `relayHealthy: false` (with `session` still populated) when `_health` fails | Phase 1 | Automated Test Coverage Required |
6262+| MM-150.AC4.4 | `load_home_data` returns `session: null` and `sessionError` populated when `getSession` fails | Phase 1 | Automated Test Coverage Required |
6363+| MM-150.AC4.5 | `load_home_data` always returns `Ok(HomeData)` — never `Err` | Phase 1 | Automated Test Coverage Required |
6464+| MM-150.AC4.6 | `log_out` deletes OAuth tokens and DID from Keychain | Phase 1 | Automated Test Coverage Required |
6565+| MM-150.AC4.7 | `log_out` always returns `Ok(())` even if Keychain delete partially fails | Phase 1 | Automated Test Coverage Required |
6666+6767+### MM-150.AC5: App launches to home when already onboarded
6868+6969+| ID | Criterion | Phase | Test Strategy |
7070+|----|-----------|-------|---------------|
7171+| MM-150.AC5.1 | App starts at the `home` step when OAuth tokens exist in Keychain on launch | Phase 4 | Human Verification |
7272+| MM-150.AC5.2 | `homeData` is loaded on mount of `HomeScreen` regardless of entry path | Phase 3, 4 | Human Verification |
7373+7474+---
7575+7676+## Automated Test Coverage Required
7777+7878+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.
7979+8080+| Criterion | Test File | Test Function | Verifies |
8181+|-----------|-----------|---------------|----------|
8282+| 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 |
8383+| MM-150.AC2.2 | `home.rs` | `load_home_data_relay_healthy_false_when_health_fails` | `relay_healthy` is `false` when mock `_health` returns 503 |
8484+| 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 |
8585+| 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 |
8686+| 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) |
8787+| 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) |
8888+| 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 |
8989+| 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 |
9090+| MM-150.AC4.1 | `home.rs` | `load_home_data_relay_healthy_true_when_health_returns_200` | `HomeData.relay_healthy == true` when `_health` returns 200 |
9191+| MM-150.AC4.2 | `home.rs` | `load_home_data_session_populated_when_get_session_succeeds` | `HomeData.session` contains correct `did`, `handle`, `email`, `emailConfirmed` |
9292+| MM-150.AC4.3 | `home.rs` | `load_home_data_relay_healthy_false_when_health_fails` | `relay_healthy == false` while `session` is still populated |
9393+| MM-150.AC4.4 | `home.rs` | `load_home_data_session_null_when_get_session_fails` | `session == None`, `session_error == Some(...)` when `getSession` returns 401 |
9494+| 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 |
9595+| MM-150.AC4.6 | `home.rs` | `log_out_deletes_oauth_and_did_from_keychain` | Three Keychain items deleted, AppState cleared |
9696+| MM-150.AC4.7 | `home.rs` | `log_out_succeeds_when_keychain_items_absent` | Function completes without panic when items are already absent |
9797+9898+Additional serialization tests (not mapped to specific ACs but validate the IPC contract):
9999+100100+| Test Function | Verifies |
101101+|---------------|----------|
102102+| `home_data_serializes_camel_case` | `HomeData` and `SessionInfo` serialize to camelCase keys matching TypeScript types |
103103+| `home_data_session_null_serializes_error_code` | `session: null` + `sessionError` serialization matches frontend expectations |
104104+105105+---
106106+107107+## Human Verification Required
108108+109109+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.
110110+111111+### Phase 2: DIDAvatar Component
112112+113113+| Criterion | Why Manual | Steps |
114114+|-----------|------------|-------|
115115+| 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). |
116116+| 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`. |
117117+| 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. |
118118+119119+### Phase 3: HomeScreen Component
120120+121121+| Criterion | Why Manual | Steps |
122122+|-----------|------------|-------|
123123+| 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`. |
124124+| 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. |
125125+| 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`). |
126126+| 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. |
127127+| 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. |
128128+| 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. |
129129+| 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. |
130130+131131+### Phase 4: State Machine Wiring
132132+133133+| Criterion | Why Manual | Steps |
134134+|-----------|------------|-------|
135135+| 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"). |
136136+| 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. |
137137+| 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"). |
138138+| 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. |
139139+| 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). |
140140+| 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). |
141141+142142+### Phase 5: DIDDocumentScreen Component
143143+144144+| Criterion | Why Manual | Steps |
145145+|-----------|------------|-------|
146146+| 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. |
147147+| 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. |
148148+| 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). |
149149+150150+### Phase 6: RecoveryInfoScreen Component
151151+152152+| Criterion | Why Manual | Steps |
153153+|-----------|------------|-------|
154154+| 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". |
155155+| 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". |
156156+| 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". |
157157+158158+---
159159+160160+## End-to-End Scenarios
161161+162162+### E2E-1: Full onboarding to home screen
163163+164164+**Purpose:** Validates the complete user journey from first launch through account creation to the home screen, exercising Phases 1-4.
165165+166166+| Step | Action | Expected |
167167+|------|--------|----------|
168168+| 1 | Launch the app on a fresh iOS Simulator (no prior Keychain data) | Welcome screen appears |
169169+| 2 | Complete the full onboarding flow (claim code, email, handle, password, DID ceremony, Shamir backup) | Each step transitions correctly |
170170+| 3 | After the `complete` step, observe the transition | App navigates to the home screen |
171171+| 4 | Verify identity card | Handle, truncated DID, email, and colored avatar are all displayed |
172172+| 5 | Verify status indicators | Relay shows "Connected" (green dot), Session shows "Active" (green dot) |
173173+| 6 | Verify action buttons | "View DID Document", "Recovery Info", and "Log Out" buttons are visible |
174174+175175+### E2E-2: Home screen to DID document and back
176176+177177+**Purpose:** Validates DID Document navigation round-trip, exercising Phases 4-5.
178178+179179+| Step | Action | Expected |
180180+|------|--------|----------|
181181+| 1 | From the home screen, tap "View DID Document" | DID Document screen appears with "DID Document" header |
182182+| 2 | Verify structured content | Identifier, verification keys, and services sections are populated |
183183+| 3 | Tap "Show Raw JSON" | Monospace JSON block appears below the structured view |
184184+| 4 | Tap "Hide Raw JSON" | JSON block disappears |
185185+| 5 | Tap the "Copy" button on a verification key | Button text changes to "Copied!" |
186186+| 6 | Tap "Back" | Returns to the home screen with all data intact |
187187+188188+### E2E-3: Home screen to recovery info and back
189189+190190+**Purpose:** Validates Recovery Info navigation round-trip, exercising Phases 4 and 6.
191191+192192+| Step | Action | Expected |
193193+|------|--------|----------|
194194+| 1 | From the home screen, tap "Recovery Info" | Recovery Info screen appears with "Recovery Info" header |
195195+| 2 | Verify Share 1 | Green checkmark, "Saved to iCloud Keychain" |
196196+| 3 | Verify Share 2 | Green checkmark, "Held by the relay" |
197197+| 4 | Verify Share 3 | Clipboard icon, "Your manual backup" |
198198+| 5 | Tap "Back" | Returns to the home screen with all data intact |
199199+200200+### E2E-4: Logout and re-authentication
201201+202202+**Purpose:** Validates that logout clears tokens and the app returns to a clean state, exercising Phases 1, 3, and 4.
203203+204204+| Step | Action | Expected |
205205+|------|--------|----------|
206206+| 1 | From the home screen, tap "Log Out" | App navigates to the welcome screen |
207207+| 2 | Force-quit and relaunch the app | Welcome screen appears (not home screen), confirming tokens were cleared |
208208+| 3 | Complete the OAuth login flow again | App navigates to the home screen with fresh session data |
209209+210210+### E2E-5: App relaunch with existing session
211211+212212+**Purpose:** Validates AC5.1 — the app launches to home when already onboarded.
213213+214214+| Step | Action | Expected |
215215+|------|--------|----------|
216216+| 1 | Start from a state where onboarding is complete and OAuth tokens exist | Home screen is showing |
217217+| 2 | Force-quit the app completely | App is terminated |
218218+| 3 | Relaunch the app | App opens directly to the home screen (not welcome) |
219219+| 4 | Verify identity card is populated | Handle, DID, email, and avatar are all displayed |
220220+221221+### E2E-6: Refresh button
222222+223223+**Purpose:** Validates that the refresh button re-fetches data without navigating away.
224224+225225+| Step | Action | Expected |
226226+|------|--------|----------|
227227+| 1 | On the home screen, note the current identity card data | Data is displayed |
228228+| 2 | Tap the refresh button (top-right corner) | Loading spinner appears briefly, then data re-renders |
229229+| 3 | Verify data is unchanged | Same handle, DID, email, and status indicators |
230230+231231+---
232232+233233+## Traceability Matrix
234234+235235+Every acceptance criterion mapped to its automated test and/or manual verification step.
236236+237237+| Acceptance Criterion | Automated Test | Manual Step |
238238+|----------------------|----------------|-------------|
239239+| MM-150.AC1.1 | -- | Phase 3: verify handle in identity card |
240240+| MM-150.AC1.2 | -- | Phase 3: verify DID truncation format |
241241+| MM-150.AC1.3 | -- | Phase 3: tap copy, paste in another app |
242242+| MM-150.AC1.4 | -- | Phase 3: verify email in identity card |
243243+| MM-150.AC1.5 | -- | Phase 2: verify colored circle, stable across relaunches |
244244+| MM-150.AC1.6 | -- | Phase 2: verify handle initial in avatar |
245245+| MM-150.AC1.7 | -- | Phase 2: verify `?` for `handle.invalid` |
246246+| MM-150.AC1.8 | -- | Phase 3: observe spinner during load |
247247+| MM-150.AC2.1 | `load_home_data_relay_healthy_true_when_health_returns_200` | -- |
248248+| MM-150.AC2.2 | `load_home_data_relay_healthy_false_when_health_fails` | -- |
249249+| MM-150.AC2.3 | `load_home_data_session_populated_when_get_session_succeeds` | -- |
250250+| MM-150.AC2.4 | `load_home_data_session_null_when_get_session_fails` | -- |
251251+| MM-150.AC2.5 | `load_home_data_relay_healthy_false_when_health_fails` + `load_home_data_session_null_when_get_session_fails` | -- |
252252+| MM-150.AC3.1 | `log_out_deletes_oauth_and_did_from_keychain` | -- |
253253+| MM-150.AC3.2 | -- | Phase 3/4: tap Log Out, verify welcome screen |
254254+| MM-150.AC3.3 | `log_out_preserves_device_and_dpop_keys` | -- |
255255+| MM-150.AC3.4 | -- | Phase 4: tap View DID Document, verify navigation |
256256+| MM-150.AC3.5 | -- | Phase 5: verify structured sections |
257257+| MM-150.AC3.6 | -- | Phase 5: toggle raw JSON |
258258+| MM-150.AC3.7 | -- | Phase 5: copy key, paste to verify |
259259+| MM-150.AC3.8 | -- | Phase 3: verify button hidden when `didDoc` is null |
260260+| MM-150.AC3.9 | -- | Phase 4/5: tap Back from DID document |
261261+| MM-150.AC3.10 | -- | Phase 4: tap Recovery Info, verify navigation |
262262+| MM-150.AC3.11 | -- | Phase 6: verify green checkmark for Share 1 |
263263+| MM-150.AC3.12 | -- | Phase 6: verify red X for Share 1 absent |
264264+| MM-150.AC3.13 | -- | Phase 6: verify green checkmark for Share 2 |
265265+| MM-150.AC3.14 | -- | Phase 4/6: tap Back from recovery info |
266266+| MM-150.AC4.1 | `load_home_data_relay_healthy_true_when_health_returns_200` | -- |
267267+| MM-150.AC4.2 | `load_home_data_session_populated_when_get_session_succeeds` | -- |
268268+| MM-150.AC4.3 | `load_home_data_relay_healthy_false_when_health_fails` | -- |
269269+| MM-150.AC4.4 | `load_home_data_session_null_when_get_session_fails` | -- |
270270+| MM-150.AC4.5 | `load_home_data_no_session_returns_not_authenticated` | -- |
271271+| MM-150.AC4.6 | `log_out_deletes_oauth_and_did_from_keychain` | -- |
272272+| MM-150.AC4.7 | `log_out_succeeds_when_keychain_items_absent` | -- |
273273+| MM-150.AC5.1 | -- | Phase 4: force-quit, relaunch, verify home |
274274+| MM-150.AC5.2 | -- | Phase 3/4: verify data loads on mount from both entry paths |
275275+276276+---
277277+278278+## Summary
279279+280280+- **Total acceptance criteria:** 33
281281+- **Automated test coverage:** 14 criteria (all in Phase 1, Rust unit tests in `home.rs`)
282282+- **Human verification required:** 19 criteria (UI rendering, clipboard, navigation, iOS Keychain state)
283283+- **End-to-end scenarios:** 6
284284+285285+### Prerequisites for Human Verification
286286+287287+- macOS with Xcode installed
288288+- iOS Simulator available (iPhone target)
289289+- `cargo tauri ios dev` running successfully
290290+- A relay instance accessible from the simulator (local or remote)
291291+- `cargo test -p identity-wallet` passing (all Phase 1 automated tests green)
···11+# Relay URL Configuration — Phase 1: RelayClient Runtime URL Support
22+33+**Goal:** Make `RelayClient` accept a runtime URL while keeping the codebase compiling.
44+55+**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.
66+77+**Tech Stack:** Rust (stable), no new dependencies
88+99+**Scope:** 1 of 4 phases
1010+1111+**Codebase verified:** 2026-03-27
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+Infrastructure phase — no ACs tested here.
1818+1919+**Verifies: None** — this phase modifies the `RelayClient` struct. Correctness is verified by `cargo build` succeeding and existing tests passing unchanged.
2020+2121+---
2222+2323+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2424+<!-- START_TASK_1 -->
2525+### Task 1: Update `RelayClient` struct and constructors in `http.rs`
2626+2727+**Files:**
2828+- Modify: `apps/identity-wallet/src-tauri/src/http.rs`
2929+3030+**Step 1: Change the `base_url` field type**
3131+3232+At `http.rs:44`, change:
3333+```rust
3434+ base_url: &'static str,
3535+```
3636+to:
3737+```rust
3838+ base_url: String,
3939+```
4040+4141+**Step 2: Update `RelayClient::new()` to use `.to_string()`**
4242+4343+At `http.rs:49-54`, change:
4444+```rust
4545+ pub fn new() -> Self {
4646+ Self {
4747+ client: Client::new(),
4848+ base_url: RELAY_BASE_URL,
4949+ }
5050+ }
5151+```
5252+to:
5353+```rust
5454+ pub fn new() -> Self {
5555+ Self {
5656+ client: Client::new(),
5757+ base_url: RELAY_BASE_URL.to_string(),
5858+ }
5959+ }
6060+```
6161+6262+**Step 3: Add `new_with_url` constructor**
6363+6464+Insert this method directly after `new()` (after the closing `}` of `new()`, before the `post` method):
6565+```rust
6666+ /// Create a new `RelayClient` with a runtime-provided base URL.
6767+ ///
6868+ /// The URL must not have a trailing slash. Used when the relay URL is
6969+ /// configured at runtime rather than baked in at compile time.
7070+ pub fn new_with_url(url: String) -> Self {
7171+ Self {
7272+ client: Client::new(),
7373+ base_url: url,
7474+ }
7575+ }
7676+```
7777+7878+**Step 4: Add an instance `base_url` accessor method**
7979+8080+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:
8181+8282+```rust
8383+ /// Returns the base URL for this relay client instance.
8484+ pub fn base_url_str(&self) -> &str {
8585+ &self.base_url
8686+ }
8787+```
8888+8989+> 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.
9090+9191+**Step 5: Verify the file still compiles**
9292+9393+From the workspace root (inside the Nix dev shell):
9494+```bash
9595+cargo build -p identity-wallet-lib 2>&1 | head -40
9696+```
9797+Expected: No errors. Warnings about unused `new_with_url` or `base_url_str` are fine.
9898+9999+If `cargo build -p identity-wallet-lib` fails (package name mismatch), use:
100100+```bash
101101+cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40
102102+```
103103+<!-- END_TASK_1 -->
104104+105105+<!-- START_TASK_2 -->
106106+### Task 2: Verify existing tests still pass and commit
107107+108108+**Files:**
109109+- No new files
110110+111111+**Step 1: Run all tests in the identity-wallet crate**
112112+113113+```bash
114114+cargo test --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1
115115+```
116116+117117+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).
118118+119119+> Note: Some tests in `oauth.rs` are marked `#[ignore]` (integration tests that need a live server). These will be skipped and that is expected.
120120+121121+**Step 2: Commit**
122122+123123+```bash
124124+git add apps/identity-wallet/src-tauri/src/http.rs
125125+git commit -m "refactor: add RelayClient::new_with_url and base_url_str for runtime URL support"
126126+```
127127+<!-- END_TASK_2 -->
128128+<!-- END_SUBCOMPONENT_A -->
···11+# Relay URL Configuration — Phase 2: AppState Integration and Command Migration
22+33+**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.
44+55+**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.
66+77+**Tech Stack:** Rust (stable), no new dependencies
88+99+**Scope:** 2 of 4 phases
1010+1111+**Codebase verified:** 2026-03-27
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+Infrastructure/refactor phase — no new ACs tested here.
1818+1919+**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.
2020+2121+---
2222+2323+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2424+<!-- START_TASK_1 -->
2525+### Task 1: Add `relay_client` to `AppState` in `oauth.rs` and add a `default_relay_url` helper in `http.rs`
2626+2727+**Files:**
2828+- Modify: `apps/identity-wallet/src-tauri/src/oauth.rs`
2929+- Modify: `apps/identity-wallet/src-tauri/src/http.rs`
3030+3131+**Step 1: Add the `relay_client` field and methods to `AppState` (`oauth.rs`)**
3232+3333+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:
3434+3535+```rust
3636+use std::sync::{Mutex, OnceLock};
3737+```
3838+3939+Then update the `AppState` struct (currently at lines 17–28) to add the new field:
4040+4141+```rust
4242+pub struct AppState {
4343+ /// The pending OAuth flow waiting for the deep-link callback.
4444+ /// Set by `start_oauth_flow` before opening Safari; cleared by `handle_deep_link`.
4545+ pub pending_auth: Mutex<Option<PendingOAuthFlow>>,
4646+ /// The active authenticated session after a successful token exchange.
4747+ /// Set by `start_oauth_flow` on success; read by `OAuthClient` for every request.
4848+ pub oauth_session: Mutex<Option<OAuthSession>>,
4949+ /// Runtime relay client. Populated from Keychain on startup (Phase 3) or by
5050+ /// `save_relay_url` on first launch. Falls back to the compile-time default if unset.
5151+ relay_client: OnceLock<crate::http::RelayClient>,
5252+}
5353+```
5454+5555+Update `AppState::new()` (currently at lines 30–36) to initialize the new field:
5656+5757+```rust
5858+impl AppState {
5959+ pub fn new() -> Self {
6060+ Self {
6161+ pending_auth: Mutex::new(None),
6262+ oauth_session: Mutex::new(None),
6363+ relay_client: OnceLock::new(),
6464+ }
6565+ }
6666+6767+ /// Returns the configured relay client, or initializes with the compile-time
6868+ /// default URL if none has been set yet.
6969+ pub fn relay_client(&self) -> &crate::http::RelayClient {
7070+ self.relay_client
7171+ .get_or_init(crate::http::RelayClient::new)
7272+ }
7373+7474+ /// Set the relay client from a runtime URL. Silently ignored if already set
7575+ /// (OnceLock::set semantics — this is only called once on first launch).
7676+ pub fn set_relay_client(&self, url: String) {
7777+ self.relay_client
7878+ .set(crate::http::RelayClient::new_with_url(url))
7979+ .ok();
8080+ }
8181+}
8282+```
8383+8484+The `Default` impl at lines 39–42 can remain unchanged (it calls `Self::new()`).
8585+8686+**Step 2: Add `pub fn default_relay_url()` to `http.rs`**
8787+8888+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):
8989+9090+```rust
9191+/// Returns the compile-time default relay base URL.
9292+///
9393+/// Used by integration tests and as the pre-filled default in the relay
9494+/// configuration UI. The runtime URL (from Keychain or user input) takes
9595+/// precedence during normal app operation.
9696+pub fn default_relay_url() -> &'static str {
9797+ RELAY_BASE_URL
9898+}
9999+```
100100+101101+**Step 3: Verify compilation**
102102+103103+```bash
104104+cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40
105105+```
106106+107107+Expected: Compiles (with possible dead-code warnings for the new methods, which is fine).
108108+<!-- END_TASK_1 -->
109109+110110+<!-- START_TASK_2 -->
111111+### Task 2: Remove `RELAY_CLIENT` static from `lib.rs` and update all four commands
112112+113113+**Files:**
114114+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs`
115115+116116+**Step 1: Remove the static declaration**
117117+118118+Delete these lines from `lib.rs` (currently lines 227–229):
119119+120120+```rust
121121+// ── Static relay client ─────────────────────────────────────────────────────
122122+123123+static RELAY_CLIENT: LazyLock<http::RelayClient> = LazyLock::new(http::RelayClient::new);
124124+```
125125+126126+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).
127127+128128+**Step 2: Update `create_account`**
129129+130130+Add `state: tauri::State<'_, oauth::AppState>` as the last parameter and replace the `RELAY_CLIENT` call:
131131+132132+Before (lines 247–252):
133133+```rust
134134+#[tauri::command]
135135+async fn create_account(
136136+ claim_code: String,
137137+ email: String,
138138+ handle: String,
139139+) -> Result<CreateAccountResult, CreateAccountError> {
140140+```
141141+142142+After:
143143+```rust
144144+#[tauri::command]
145145+async fn create_account(
146146+ claim_code: String,
147147+ email: String,
148148+ handle: String,
149149+ state: tauri::State<'_, oauth::AppState>,
150150+) -> Result<CreateAccountResult, CreateAccountError> {
151151+```
152152+153153+Replace `RELAY_CLIENT` at line 268:
154154+```rust
155155+ let resp = RELAY_CLIENT
156156+ .post("/v1/accounts/mobile", &req)
157157+```
158158+becomes:
159159+```rust
160160+ let resp = state
161161+ .relay_client()
162162+ .post("/v1/accounts/mobile", &req)
163163+```
164164+165165+**Step 3: Update `perform_did_ceremony`**
166166+167167+Add `state: tauri::State<'_, oauth::AppState>` as the last parameter:
168168+169169+Before (lines 332–336):
170170+```rust
171171+#[tauri::command]
172172+async fn perform_did_ceremony(
173173+ handle: String,
174174+ password: String,
175175+) -> Result<DIDCeremonyResult, DIDCeremonyError> {
176176+```
177177+178178+After:
179179+```rust
180180+#[tauri::command]
181181+async fn perform_did_ceremony(
182182+ handle: String,
183183+ password: String,
184184+ state: tauri::State<'_, oauth::AppState>,
185185+) -> Result<DIDCeremonyResult, DIDCeremonyError> {
186186+```
187187+188188+Replace `RELAY_CLIENT` at line 345:
189189+```rust
190190+ let resp =
191191+ RELAY_CLIENT
192192+ .get("/v1/relay/keys")
193193+```
194194+becomes:
195195+```rust
196196+ let resp =
197197+ state
198198+ .relay_client()
199199+ .get("/v1/relay/keys")
200200+```
201201+202202+Replace `http::RelayClient::base_url()` at line 379:
203203+```rust
204204+ http::RelayClient::base_url(),
205205+```
206206+becomes:
207207+```rust
208208+ state.relay_client().base_url_str(),
209209+```
210210+211211+Replace `RELAY_CLIENT` at line 410:
212212+```rust
213213+ let resp = RELAY_CLIENT
214214+ .post_with_bearer("/v1/dids", &create_did_req, &pending_token)
215215+```
216216+becomes:
217217+```rust
218218+ let resp = state
219219+ .relay_client()
220220+ .post_with_bearer("/v1/dids", &create_did_req, &pending_token)
221221+```
222222+223223+**Step 4: Update `register_handle`**
224224+225225+Add `state: tauri::State<'_, oauth::AppState>` as the last parameter:
226226+227227+Before (lines 472–475):
228228+```rust
229229+#[tauri::command]
230230+async fn register_handle(
231231+ handle_label: String,
232232+) -> Result<RegisterHandleResult, RegisterHandleError> {
233233+```
234234+235235+After:
236236+```rust
237237+#[tauri::command]
238238+async fn register_handle(
239239+ handle_label: String,
240240+ state: tauri::State<'_, oauth::AppState>,
241241+) -> Result<RegisterHandleResult, RegisterHandleError> {
242242+```
243243+244244+Replace `RELAY_CLIENT` at line 477:
245245+```rust
246246+ let resp = RELAY_CLIENT
247247+ .get("/xrpc/com.atproto.server.describeServer")
248248+```
249249+becomes:
250250+```rust
251251+ let resp = state
252252+ .relay_client()
253253+ .get("/xrpc/com.atproto.server.describeServer")
254254+```
255255+256256+Replace `RELAY_CLIENT` at line 531:
257257+```rust
258258+ let resp = RELAY_CLIENT
259259+ .post_with_bearer("/v1/handles", &req, &session_token)
260260+```
261261+becomes:
262262+```rust
263263+ let resp = state
264264+ .relay_client()
265265+ .post_with_bearer("/v1/handles", &req, &session_token)
266266+```
267267+268268+**Step 5: Update `check_handle_resolution`**
269269+270270+Add `state: tauri::State<'_, oauth::AppState>` as the last parameter:
271271+272272+Before (line 585):
273273+```rust
274274+#[tauri::command]
275275+async fn check_handle_resolution(handle: String, expected_did: String) -> bool {
276276+```
277277+278278+After:
279279+```rust
280280+#[tauri::command]
281281+async fn check_handle_resolution(
282282+ handle: String,
283283+ expected_did: String,
284284+ state: tauri::State<'_, oauth::AppState>,
285285+) -> bool {
286286+```
287287+288288+Replace `RELAY_CLIENT` at line 589:
289289+```rust
290290+ let resp = match RELAY_CLIENT.get(&path).await {
291291+```
292292+becomes:
293293+```rust
294294+ let resp = match state.relay_client().get(&path).await {
295295+```
296296+297297+**Step 6: Verify compilation**
298298+299299+```bash
300300+cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40
301301+```
302302+303303+Expected: Compiles. If there are errors about `LazyLock` being unused or not found, double-check the import removal in Step 1.
304304+<!-- END_TASK_2 -->
305305+<!-- END_SUBCOMPONENT_A -->
306306+307307+<!-- START_SUBCOMPONENT_B (tasks 3-4) -->
308308+<!-- START_TASK_3 -->
309309+### Task 3: Update `OAuthClient::new()` in `oauth_client.rs` and its call site in `home.rs`
310310+311311+**Files:**
312312+- Modify: `apps/identity-wallet/src-tauri/src/oauth_client.rs`
313313+- Modify: `apps/identity-wallet/src-tauri/src/home.rs`
314314+315315+**Step 1: Add `base_url` parameter to `OAuthClient::new()` (`oauth_client.rs`)**
316316+317317+Current `new()` at lines 37–44:
318318+```rust
319319+pub fn new(session: Arc<Mutex<OAuthSession>>) -> Result<Self, OAuthError> {
320320+ let dpop = DPoPKeypair::get_or_create()?;
321321+ Ok(Self {
322322+ inner: Client::new(),
323323+ dpop,
324324+ session,
325325+ base_url: crate::http::RelayClient::base_url().to_string(),
326326+ })
327327+}
328328+```
329329+330330+Replace with:
331331+```rust
332332+pub fn new(session: Arc<Mutex<OAuthSession>>, base_url: String) -> Result<Self, OAuthError> {
333333+ let dpop = DPoPKeypair::get_or_create()?;
334334+ Ok(Self {
335335+ inner: Client::new(),
336336+ dpop,
337337+ session,
338338+ base_url,
339339+ })
340340+}
341341+```
342342+343343+**Step 2: Update the `OAuthClient::new()` call in `home.rs`**
344344+345345+In `home.rs` at line 83:
346346+```rust
347347+ let oauth_client = match crate::oauth_client::OAuthClient::new(session_arc.clone()) {
348348+```
349349+becomes:
350350+```rust
351351+ let oauth_client = match crate::oauth_client::OAuthClient::new(
352352+ session_arc.clone(),
353353+ state.relay_client().base_url_str().to_owned(),
354354+ ) {
355355+```
356356+357357+The same `state` parameter that `load_home_data` already receives (`state: tauri::State<'_, AppState>`) is used here.
358358+359359+**Step 3: Update the private `check_relay_health` helper in `home.rs`**
360360+361361+Rename `check_relay_health` to `ping_relay_health` (to avoid ambiguity with any future public IPC commands) and add a `relay_client` parameter.
362362+363363+Current at lines 165–171:
364364+```rust
365365+async fn check_relay_health() -> bool {
366366+ crate::http::RelayClient::new()
367367+ .get("/xrpc/_health")
368368+ .await
369369+ .map(|r| r.status().is_success())
370370+ .unwrap_or(false)
371371+}
372372+```
373373+374374+Replace with:
375375+```rust
376376+async fn ping_relay_health(relay_client: &crate::http::RelayClient) -> bool {
377377+ relay_client
378378+ .get("/xrpc/_health")
379379+ .await
380380+ .map(|r| r.status().is_success())
381381+ .unwrap_or(false)
382382+}
383383+```
384384+385385+Update the three call sites in `load_home_data` (lines 72, 88, 97):
386386+387387+- Line 72: `check_relay_health().await` → `ping_relay_health(state.relay_client()).await`
388388+- Line 88: `check_relay_health().await` → `ping_relay_health(state.relay_client()).await`
389389+- Line 97: `check_relay_health()` → `ping_relay_health(state.relay_client())`
390390+391391+**Step 4: Verify compilation**
392392+393393+```bash
394394+cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40
395395+```
396396+397397+Expected: Compiles cleanly.
398398+<!-- END_TASK_3 -->
399399+400400+<!-- START_TASK_4 -->
401401+### Task 4: Update `start_oauth_flow` in `oauth.rs` and fix test static calls
402402+403403+**Files:**
404404+- Modify: `apps/identity-wallet/src-tauri/src/oauth.rs`
405405+406406+**Step 1: Update `start_oauth_flow` to use `state.relay_client()`**
407407+408408+`start_oauth_flow` at line 384 creates a local `RelayClient`:
409409+```rust
410410+ let relay = crate::http::RelayClient::new();
411411+```
412412+Replace with:
413413+```rust
414414+ let relay = state.relay_client();
415415+```
416416+417417+At line 394, replace:
418418+```rust
419419+ let par_htu = format!("{}/oauth/par", crate::http::RelayClient::base_url());
420420+```
421421+with:
422422+```rust
423423+ let par_htu = format!("{}/oauth/par", state.relay_client().base_url_str());
424424+```
425425+426426+At line 421, replace:
427427+```rust
428428+ let base = crate::http::RelayClient::base_url();
429429+```
430430+with:
431431+```rust
432432+ let base = state.relay_client().base_url_str();
433433+```
434434+435435+At line 449, replace:
436436+```rust
437437+ let token_htu = format!("{}/oauth/token", crate::http::RelayClient::base_url());
438438+```
439439+with:
440440+```rust
441441+ let token_htu = format!("{}/oauth/token", state.relay_client().base_url_str());
442442+```
443443+444444+**Step 2: Fix the two test usages of `RelayClient::base_url()` (lines 786, 818)**
445445+446446+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.
447447+448448+At line 786 (inside `par_integration_returns_201_with_request_uri`):
449449+```rust
450450+ let htu = format!("{}/oauth/par", crate::http::RelayClient::base_url());
451451+```
452452+becomes:
453453+```rust
454454+ let htu = format!("{}/oauth/par", crate::http::default_relay_url());
455455+```
456456+457457+At lines 818–819 (inside `par_missing_code_challenge_returns_client_error`):
458458+```rust
459459+ let base_url = crate::http::RelayClient::base_url();
460460+ let url = format!("{base_url}/oauth/par");
461461+```
462462+becomes:
463463+```rust
464464+ let base_url = crate::http::default_relay_url();
465465+ let url = format!("{base_url}/oauth/par");
466466+```
467467+468468+**Step 3: Remove the now-unused static `base_url()` method from `http.rs`**
469469+470470+In `http.rs`, delete the static `base_url()` method (currently lines 192–197):
471471+472472+```rust
473473+ /// Returns the compile-time base URL for this relay client instance.
474474+ ///
475475+ /// Used as the `service_endpoint` parameter in DID ceremony genesis op construction.
476476+ pub const fn base_url() -> &'static str {
477477+ RELAY_BASE_URL
478478+ }
479479+```
480480+481481+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.
482482+483483+**Step 4: Run all tests**
484484+485485+```bash
486486+cargo test --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1
487487+```
488488+489489+Expected: All non-ignored tests pass. The `#[ignore]` integration tests in `oauth.rs` are skipped (expected).
490490+491491+Verify zero references to `RELAY_CLIENT` remain:
492492+```bash
493493+grep -r "RELAY_CLIENT" apps/identity-wallet/src-tauri/src/
494494+```
495495+Expected: No output.
496496+497497+Verify zero references to `RelayClient::base_url()` as a static call remain:
498498+```bash
499499+grep -rn "RelayClient::base_url()" apps/identity-wallet/src-tauri/src/
500500+```
501501+Expected: No output.
502502+503503+**Step 5: Commit**
504504+505505+```bash
506506+git add apps/identity-wallet/src-tauri/src/
507507+git commit -m "refactor: move RelayClient to AppState, remove RELAY_CLIENT static"
508508+```
509509+<!-- END_TASK_4 -->
510510+<!-- END_SUBCOMPONENT_B -->
···11+# Relay URL Configuration — Phase 3: IPC Commands and Startup Initialization
22+33+**Goal:** Expose relay URL configuration to the frontend and initialize the relay client from Keychain on startup.
44+55+**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.
66+77+**Tech Stack:** Rust (stable), `url` crate (already in Cargo.toml), `reqwest` (already in Cargo.toml)
88+99+**Scope:** 3 of 4 phases
1010+1111+**Codebase verified:** 2026-03-27
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+### relay-url-config.AC3: URL persists across restarts
1818+- **relay-url-config.AC3.1 Success:** After saving a URL and relaunching the app, the relay config screen is not shown
1919+- **relay-url-config.AC3.2 Success:** All relay IPC commands on subsequent launches use the saved URL
2020+2121+### relay-url-config.AC4: Relay reachability verified before saving
2222+- **relay-url-config.AC4.1 Success:** A URL whose `/xrpc/_health` returns HTTP 200 is accepted
2323+- **relay-url-config.AC4.2 Failure:** An unreachable host surfaces an `UNREACHABLE` inline error
2424+- **relay-url-config.AC4.3 Failure:** A malformed URL (not `http`/`https`, empty host) surfaces an `INVALID_URL` error before any network call
2525+- **relay-url-config.AC4.4 Edge:** A URL with a trailing slash is accepted and normalized (slash stripped) before saving
2626+2727+---
2828+2929+<!-- START_SUBCOMPONENT_A (tasks 1-3) -->
3030+<!-- START_TASK_1 -->
3131+### Task 1: Add Keychain helpers for relay URL in `keychain.rs`
3232+3333+**Files:**
3434+- Modify: `apps/identity-wallet/src-tauri/src/keychain.rs`
3535+3636+**Step 1: Add the account name constant**
3737+3838+In `keychain.rs`, find the existing account name constants (they look like `const DPOP_KEY_PRIV_ACCOUNT: &str = "..."`). Add a new constant alongside them:
3939+4040+```rust
4141+const RELAY_URL_ACCOUNT: &str = "relay-base-url";
4242+```
4343+4444+**Step 2: Add `store_relay_url` and `load_relay_url` helpers**
4545+4646+Add these helper functions at the bottom of the public helper section (after the existing `store_oauth_tokens` / `load_oauth_tokens` functions):
4747+4848+```rust
4949+/// Persist the user-configured relay base URL to the Keychain.
5050+///
5151+/// Overwrites any previously stored URL.
5252+pub fn store_relay_url(url: &str) -> Result<(), KeychainError> {
5353+ store_item(RELAY_URL_ACCOUNT, url.as_bytes())
5454+}
5555+5656+/// Retrieve the user-configured relay base URL from the Keychain.
5757+///
5858+/// Returns `None` if no URL has been saved yet (first run or after logout).
5959+pub fn load_relay_url() -> Option<String> {
6060+ match get_item(RELAY_URL_ACCOUNT) {
6161+ Ok(bytes) => String::from_utf8(bytes)
6262+ .map_err(|e| {
6363+ tracing::warn!(error = %e, "relay URL in Keychain is not valid UTF-8; treating as absent");
6464+ })
6565+ .ok(),
6666+ Err(e) if is_not_found(&e) => None,
6767+ Err(e) => {
6868+ tracing::error!(error = ?e, "Keychain error loading relay URL");
6969+ None
7070+ }
7171+ }
7272+}
7373+7474+/// Remove the relay URL from the Keychain. Test-only; used to reset state
7575+/// between tests that share the Keychain mock store.
7676+#[cfg(test)]
7777+pub fn delete_relay_url_test_only() {
7878+ let _ = delete_item(RELAY_URL_ACCOUNT);
7979+}
8080+```
8181+8282+**Step 3: Verify compilation**
8383+8484+```bash
8585+cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -20
8686+```
8787+8888+Expected: Compiles.
8989+<!-- END_TASK_1 -->
9090+9191+<!-- START_TASK_2 -->
9292+### Task 2: Add `RelayConfigError` and the three IPC commands in `lib.rs`
9393+9494+**Files:**
9595+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs`
9696+9797+**Step 1: Add `RelayConfigError`**
9898+9999+Add this enum to `lib.rs` alongside the other error enums (e.g., after `CreateAccountError`). Follow the exact same derive pattern used by `CreateAccountError`:
100100+101101+```rust
102102+/// Error returned by relay URL configuration commands.
103103+///
104104+/// Serializes as `{ "code": "INVALID_URL" | "UNREACHABLE" | "KEYCHAIN_ERROR" }` for the frontend.
105105+#[derive(Debug, Serialize, thiserror::Error)]
106106+#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]
107107+pub enum RelayConfigError {
108108+ #[error("invalid relay URL: must be http or https with a non-empty host")]
109109+ InvalidUrl,
110110+ #[error("relay is unreachable or did not return a success response")]
111111+ Unreachable,
112112+ #[error("failed to save relay URL to device storage")]
113113+ KeychainError,
114114+}
115115+```
116116+117117+**Step 2: Add URL validation helper**
118118+119119+Add a private helper function near the bottom of the helpers section (near `map_409_subcode`):
120120+121121+```rust
122122+/// Validate a relay URL: must parse as http or https with a non-empty host.
123123+/// Strips any trailing slash and returns the normalized URL string.
124124+fn normalize_relay_url(url: &str) -> Result<String, RelayConfigError> {
125125+ let parsed = url::Url::parse(url).map_err(|_| RelayConfigError::InvalidUrl)?;
126126+ match parsed.scheme() {
127127+ "http" | "https" => {}
128128+ _ => return Err(RelayConfigError::InvalidUrl),
129129+ }
130130+ if parsed.host().is_none() {
131131+ return Err(RelayConfigError::InvalidUrl);
132132+ }
133133+ Ok(url.trim_end_matches('/').to_string())
134134+}
135135+```
136136+137137+**Step 3: Add the two IPC commands**
138138+139139+Add these two commands to `lib.rs`, grouped after the existing IPC commands (before `run()`):
140140+141141+```rust
142142+/// Return the saved relay base URL, or `None` if not yet configured.
143143+///
144144+/// The frontend calls this on mount to decide whether to show the relay
145145+/// configuration screen.
146146+#[tauri::command]
147147+fn get_relay_url() -> Option<String> {
148148+ keychain::load_relay_url()
149149+}
150150+151151+/// Validate `url`, confirm the relay is reachable, save to Keychain, and
152152+/// initialize the runtime relay client.
153153+///
154154+/// After this call succeeds, all subsequent IPC commands that use the relay
155155+/// will use the saved URL for the remainder of the app session and on all
156156+/// future launches.
157157+#[tauri::command]
158158+async fn save_relay_url(
159159+ url: String,
160160+ state: tauri::State<'_, oauth::AppState>,
161161+) -> Result<(), RelayConfigError> {
162162+ let normalized = normalize_relay_url(&url)?;
163163+ let resp = http::RelayClient::new_with_url(normalized.clone())
164164+ .get("/xrpc/_health")
165165+ .await
166166+ .map_err(|_| RelayConfigError::Unreachable)?;
167167+ if !resp.status().is_success() {
168168+ tracing::warn!(
169169+ status = %resp.status(),
170170+ url = %normalized,
171171+ "relay health check returned non-success status"
172172+ );
173173+ return Err(RelayConfigError::Unreachable);
174174+ }
175175+ keychain::store_relay_url(&normalized).map_err(|e| {
176176+ tracing::error!(error = %e, "failed to save relay URL to Keychain");
177177+ RelayConfigError::KeychainError
178178+ })?;
179179+ state.set_relay_client(normalized);
180180+ Ok(())
181181+}
182182+```
183183+184184+**Step 4: Register the new commands in `invoke_handler`**
185185+186186+Update the `tauri::generate_handler!` macro in `run()`:
187187+188188+```rust
189189+ .invoke_handler(tauri::generate_handler![
190190+ create_account,
191191+ get_or_create_device_key,
192192+ sign_with_device_key,
193193+ perform_did_ceremony,
194194+ register_handle,
195195+ check_handle_resolution,
196196+ get_relay_url,
197197+ save_relay_url,
198198+ home::load_home_data,
199199+ home::log_out,
200200+ oauth::start_oauth_flow,
201201+ ])
202202+```
203203+204204+**Step 5: Verify compilation**
205205+206206+```bash
207207+cargo build --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1 | head -40
208208+```
209209+210210+Expected: Compiles cleanly.
211211+<!-- END_TASK_2 -->
212212+213213+<!-- START_TASK_3 -->
214214+### Task 3: Initialize relay client from Keychain on startup, write tests, and commit
215215+216216+**Files:**
217217+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs`
218218+219219+**Step 1: Add Keychain relay URL initialization to `run()` setup block**
220220+221221+In the `run()` function, in the `.setup(|app| { ... })` closure, add relay URL initialization **before** the existing OAuth token restore block:
222222+223223+```rust
224224+ .setup(|app| {
225225+ // Restore relay URL from Keychain if previously configured.
226226+ if let Some(url) = keychain::load_relay_url() {
227227+ app.state::<oauth::AppState>().set_relay_client(url);
228228+ }
229229+230230+ let app_handle = app.app_handle().clone();
231231+ app.deep_link().on_open_url(move |event| {
232232+ // ... existing deep-link handler unchanged
233233+ });
234234+235235+ // ... existing OAuth token restore block unchanged
236236+```
237237+238238+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).
239239+240240+**Step 2: Write tests**
241241+242242+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.
243243+244244+```rust
245245+ // -- normalize_relay_url --
246246+247247+ #[test]
248248+ fn normalize_relay_url_strips_trailing_slash() {
249249+ assert_eq!(
250250+ normalize_relay_url("https://relay.example.com/").unwrap(),
251251+ "https://relay.example.com"
252252+ );
253253+ }
254254+255255+ #[test]
256256+ fn normalize_relay_url_accepts_http_and_https() {
257257+ assert!(normalize_relay_url("https://relay.example.com").is_ok());
258258+ assert!(normalize_relay_url("http://localhost:8080").is_ok());
259259+ }
260260+261261+ #[test]
262262+ fn normalize_relay_url_rejects_non_http_schemes() {
263263+ assert!(matches!(
264264+ normalize_relay_url("ftp://relay.example.com").unwrap_err(),
265265+ RelayConfigError::InvalidUrl
266266+ ));
267267+ assert!(matches!(
268268+ normalize_relay_url("ws://relay.example.com").unwrap_err(),
269269+ RelayConfigError::InvalidUrl
270270+ ));
271271+ }
272272+273273+ #[test]
274274+ fn normalize_relay_url_rejects_malformed_input() {
275275+ assert!(matches!(
276276+ normalize_relay_url("not-a-url").unwrap_err(),
277277+ RelayConfigError::InvalidUrl
278278+ ));
279279+ assert!(matches!(
280280+ normalize_relay_url("").unwrap_err(),
281281+ RelayConfigError::InvalidUrl
282282+ ));
283283+ }
284284+285285+ // -- get_relay_url / load_relay_url round-trip --
286286+287287+ #[test]
288288+ fn get_relay_url_returns_none_before_save() {
289289+ // The Keychain test mock starts empty in a fresh process; tests that
290290+ // write to the store must clean up via delete_relay_url_test_only().
291291+ assert!(get_relay_url().is_none());
292292+ }
293293+294294+ #[test]
295295+ fn relay_url_round_trips_through_keychain() {
296296+ let url = "https://relay.example.com";
297297+ keychain::store_relay_url(url).unwrap();
298298+ let loaded = keychain::load_relay_url().unwrap();
299299+ assert_eq!(loaded, url);
300300+ // Clean up so this test doesn't affect others sharing the mock store.
301301+ keychain::delete_relay_url_test_only();
302302+ }
303303+```
304304+305305+> 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.
306306+>
307307+> `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.
308308+309309+**Step 3: Run tests**
310310+311311+```bash
312312+cargo test --manifest-path apps/identity-wallet/src-tauri/Cargo.toml 2>&1
313313+```
314314+315315+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.
316316+317317+**Step 4: Commit**
318318+319319+```bash
320320+git add apps/identity-wallet/src-tauri/src/keychain.rs \
321321+ apps/identity-wallet/src-tauri/src/lib.rs
322322+git commit -m "feat: add relay URL IPC commands and Keychain persistence"
323323+```
324324+<!-- END_TASK_3 -->
325325+<!-- END_SUBCOMPONENT_A -->
···11+# relay-url-config: Test Requirements
22+33+**Feature:** relay-url-config -- Relay URL Configuration
44+**Design plan:** `docs/design-plans/2026-03-27-relay-url-config.md`
55+**Last verified:** 2026-03-27
66+77+---
88+99+## Acceptance Criteria Index
1010+1111+Every acceptance criterion from the design plan, mapped to its implementing phase and test strategy.
1212+1313+### relay-url-config.AC1: Relay config screen shown on first launch
1414+1515+| ID | Criterion | Phase | Test Strategy |
1616+|----|-----------|-------|---------------|
1717+| 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 |
1818+| relay-url-config.AC1.2 | User can accept the pre-filled default URL and proceed to welcome | Phase 4 | Human Verification |
1919+| relay-url-config.AC1.3 | User can enter a custom URL and proceed if the relay is healthy | Phase 4 | Human Verification |
2020+| relay-url-config.AC1.4 | User cannot advance past the config screen without a valid, reachable URL | Phase 4 | Human Verification |
2121+2222+### relay-url-config.AC2: Default URL pre-filled
2323+2424+| ID | Criterion | Phase | Test Strategy |
2525+|----|-----------|-------|---------------|
2626+| relay-url-config.AC2.1 | URL input is pre-filled with `https://relay.ezpds.com` on first launch | Phase 4 | Human Verification |
2727+2828+### relay-url-config.AC3: URL persists across restarts
2929+3030+| ID | Criterion | Phase | Test Strategy |
3131+|----|-----------|-------|---------------|
3232+| 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 |
3333+| relay-url-config.AC3.2 | All relay IPC commands on subsequent launches use the saved URL | Phase 3 | Automated Test Coverage Required |
3434+3535+### relay-url-config.AC4: Relay reachability verified before saving
3636+3737+| ID | Criterion | Phase | Test Strategy |
3838+|----|-----------|-------|---------------|
3939+| relay-url-config.AC4.1 | A URL whose `/xrpc/_health` returns HTTP 200 is accepted | Phase 3 | Human Verification |
4040+| relay-url-config.AC4.2 | An unreachable host surfaces an `UNREACHABLE` inline error | Phase 3, 4 | Human Verification |
4141+| 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 |
4242+| 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 |
4343+4444+### relay-url-config.AC5: Returning users skip config screen
4545+4646+| ID | Criterion | Phase | Test Strategy |
4747+|----|-----------|-------|---------------|
4848+| 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 |
4949+| 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 |
5050+5151+### relay-url-config.AC6: Error and loading states
5252+5353+| ID | Criterion | Phase | Test Strategy |
5454+|----|-----------|-------|---------------|
5555+| relay-url-config.AC6.1 | A loading/spinner state is shown while the health check is in flight | Phase 4 | Human Verification |
5656+| relay-url-config.AC6.2 | `INVALID_URL` error is shown inline on the config screen (user stays on screen) | Phase 4 | Human Verification |
5757+| relay-url-config.AC6.3 | `UNREACHABLE` error is shown inline on the config screen (user stays on screen) | Phase 4 | Human Verification |
5858+5959+---
6060+6161+## Automated Test Coverage Required
6262+6363+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.
6464+6565+| Criterion | Test File | Test Function | Verifies |
6666+|-----------|-----------|---------------|----------|
6767+| relay-url-config.AC4.3 | `lib.rs` | `normalize_relay_url_rejects_non_http_schemes` | `ftp://` and `ws://` URLs return `RelayConfigError::InvalidUrl` |
6868+| relay-url-config.AC4.3 | `lib.rs` | `normalize_relay_url_rejects_malformed_input` | Empty string and non-URL string return `RelayConfigError::InvalidUrl` |
6969+| relay-url-config.AC4.3 | `lib.rs` | `normalize_relay_url_accepts_http_and_https` | `https://` and `http://` URLs are accepted without error |
7070+| relay-url-config.AC4.4 | `lib.rs` | `normalize_relay_url_strips_trailing_slash` | `https://relay.example.com/` becomes `https://relay.example.com` |
7171+| 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` |
7272+| 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 |
7373+7474+---
7575+7676+## Human Verification Required
7777+7878+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.
7979+8080+### Phase 3: IPC Commands (live relay required)
8181+8282+| Criterion | Why Manual | Steps |
8383+|-----------|------------|-------|
8484+| 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. |
8585+| 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. |
8686+| 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). |
8787+8888+### Phase 4: Frontend Relay Configuration Screen
8989+9090+| Criterion | Why Manual | Steps |
9191+|-----------|------------|-------|
9292+| 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. |
9393+| 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. |
9494+| 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. |
9595+| 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. |
9696+| 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`. |
9797+| 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). |
9898+| 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. |
9999+| 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. |
100100+| 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. |
101101+| 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. |
102102+103103+---
104104+105105+## End-to-End Scenarios
106106+107107+### E2E-1: First launch -- configure relay and begin onboarding
108108+109109+**Purpose:** Validates the complete first-launch path from relay configuration through the start of onboarding, exercising Phases 3-4.
110110+111111+| Step | Action | Expected |
112112+|------|--------|----------|
113113+| 1 | Reset the iOS Simulator (Erase All Content and Settings) | Simulator is clean |
114114+| 2 | Launch the app via `cargo tauri ios dev` | Relay config screen appears with "Connect to Relay" header |
115115+| 3 | Verify the URL input is pre-filled | Input contains `https://relay.ezpds.com` |
116116+| 4 | Tap Connect (with a reachable relay) | Spinner appears, then app advances to the welcome screen |
117117+| 5 | Verify the welcome screen is functional | "Get Started" button is visible and tappable |
118118+119119+### E2E-2: First launch -- invalid URL then recovery
120120+121121+**Purpose:** Validates error handling and recovery on the relay config screen, exercising Phases 3-4.
122122+123123+| Step | Action | Expected |
124124+|------|--------|----------|
125125+| 1 | Reset the iOS Simulator | Simulator is clean |
126126+| 2 | Launch the app | Relay config screen appears |
127127+| 3 | Clear the URL field and type `notaurl` | Connect button is disabled (URL does not start with http/https) |
128128+| 4 | Clear the field and type `https://does-not-exist.example.com` | Connect button is enabled |
129129+| 5 | Tap Connect | Spinner appears, then inline error: "Could not reach the relay..." |
130130+| 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 |
131131+| 7 | Tap Connect | Spinner appears, then app advances to the welcome screen |
132132+133133+### E2E-3: Returning user -- relay config screen skipped
134134+135135+**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.
136136+137137+| Step | Action | Expected |
138138+|------|--------|----------|
139139+| 1 | Start from a state where the relay URL has been saved (E2E-1 completed) | App is on the welcome screen or further |
140140+| 2 | Force-quit the app completely | App is terminated |
141141+| 3 | Relaunch the app | App opens directly to the welcome screen (not the relay config screen) |
142142+| 4 | If OAuth tokens also exist in Keychain, verify the app opens to the home screen instead | Home screen with identity card is displayed |
143143+144144+### E2E-4: Full journey -- relay config through account creation
145145+146146+**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.
147147+148148+| Step | Action | Expected |
149149+|------|--------|----------|
150150+| 1 | Reset the iOS Simulator | Simulator is clean |
151151+| 2 | Launch the app | Relay config screen appears |
152152+| 3 | Enter a valid relay URL and tap Connect | App advances to the welcome screen |
153153+| 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 |
154154+| 5 | After the `complete` step, verify the home screen | Identity card shows handle, DID, email; relay status is "Connected" |
155155+| 6 | Force-quit and relaunch | App opens to home screen (both relay URL and OAuth tokens are restored from Keychain) |
156156+157157+### E2E-5: Custom relay URL -- self-hosted deployment
158158+159159+**Purpose:** Validates that a non-default relay URL works end-to-end for self-hosted deployments.
160160+161161+| Step | Action | Expected |
162162+|------|--------|----------|
163163+| 1 | Start a local relay instance (`cargo run -p relay`) | Relay is listening on `http://localhost:2583` |
164164+| 2 | Reset the iOS Simulator and launch the app | Relay config screen appears |
165165+| 3 | Clear the default URL, enter `http://localhost:2583`, and tap Connect | Spinner, then app advances to the welcome screen |
166166+| 4 | Proceed through onboarding | All commands succeed against the local relay |
167167+| 5 | Force-quit and relaunch | App opens to home; relay status shows "Connected" (to the local relay) |
168168+169169+---
170170+171171+## Traceability Matrix
172172+173173+Every acceptance criterion mapped to its automated test and/or manual verification step.
174174+175175+| Acceptance Criterion | Automated Test | Manual Step |
176176+|----------------------|----------------|-------------|
177177+| relay-url-config.AC1.1 | -- | Phase 4: verify config screen appears first on fresh launch |
178178+| relay-url-config.AC1.2 | -- | Phase 4: accept default URL, verify advancement to welcome |
179179+| relay-url-config.AC1.3 | -- | Phase 4: enter custom URL, verify advancement to welcome |
180180+| relay-url-config.AC1.4 | -- | Phase 4: enter invalid/unreachable URL, verify screen blocks advancement |
181181+| relay-url-config.AC2.1 | -- | Phase 4: verify input pre-filled with `https://relay.ezpds.com` |
182182+| relay-url-config.AC3.1 | -- | Phase 4: save URL, force-quit, relaunch, verify config screen is skipped |
183183+| relay-url-config.AC3.2 | `relay_url_round_trips_through_keychain` + `get_relay_url_returns_none_before_save` | -- |
184184+| relay-url-config.AC4.1 | -- | Phase 3: connect to a live relay, verify acceptance |
185185+| relay-url-config.AC4.2 | -- | Phase 3/4: enter unreachable URL, verify inline error |
186186+| 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` | -- |
187187+| relay-url-config.AC4.4 | `normalize_relay_url_strips_trailing_slash` | -- |
188188+| relay-url-config.AC5.1 | -- | Phase 4: relaunch with saved URL, verify config screen skipped |
189189+| relay-url-config.AC5.2 | -- | Phase 3: save URL, then proceed through onboarding in same session |
190190+| relay-url-config.AC6.1 | -- | Phase 4: verify spinner during health check |
191191+| relay-url-config.AC6.2 | -- | Phase 4: verify `INVALID_URL` inline error display |
192192+| relay-url-config.AC6.3 | -- | Phase 4: verify `UNREACHABLE` inline error display |
193193+194194+---
195195+196196+## Summary
197197+198198+- **Total acceptance criteria:** 16
199199+- **Automated test coverage:** 3 criteria (AC3.2, AC4.3, AC4.4 -- Rust unit tests in `lib.rs`)
200200+- **Human verification required:** 13 criteria (UI rendering, navigation gating, live relay health check, Keychain persistence across restarts)
201201+- **End-to-end scenarios:** 5
202202+203203+### Prerequisites for Human Verification
204204+205205+- macOS with Xcode installed
206206+- iOS Simulator available (iPhone target)
207207+- `cargo tauri ios dev` running successfully
208208+- A relay instance accessible from the simulator (local via `cargo run -p relay` or remote production)
209209+- `cargo test -p identity-wallet` passing (all Phase 3 automated tests green)