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.

docs: add relay URL configuration design plan

Completed brainstorming session. Design includes:
- AppState-held OnceLock<RelayClient> replacing compile-time static singleton
- Three new IPC commands: get_relay_url, check_relay_health, save_relay_url
- 4 implementation phases: RelayClient refactor, AppState migration, IPC commands, frontend screen

authored by

Malpercio and committed by
Tangled
8ce0ea38 04ad49c5

+183
+183
docs/design-plans/2026-03-27-relay-url-config.md
··· 1 + # Relay URL Configuration Design 2 + 3 + ## Summary 4 + 5 + The relay URL configuration feature makes the identity wallet's backend relay address configurable at runtime instead of hardcoding it at compile time. On first launch, users see a new configuration screen pre-filled with the production relay URL (`https://relay.ezpds.com`); they can accept it or supply a custom URL for development or self-hosted deployments. Before saving, the app pings the relay's `/xrpc/_health` endpoint to confirm it is reachable, surfacing inline errors if the URL is malformed or the host is unreachable. Once saved, the URL is written to the iOS Keychain so it survives app restarts and the configuration screen is never shown again. 6 + 7 + On the Rust side, `RelayClient` is refactored from a compile-time static singleton into a runtime-initialized instance held in `AppState` behind a `OnceLock`. During startup, the app reads the saved URL from Keychain and populates the lock; if no URL is saved yet, the compile-time default serves as a fallback until the user completes first-time configuration. Three new Tauri IPC commands (`get_relay_url`, `check_relay_health`, `save_relay_url`) bridge the frontend configuration screen to the Rust backend. The implementation is structured in four sequential phases — refactoring `RelayClient`, migrating `AppState`, adding IPC commands, then adding the frontend screen — so each phase can be built and verified independently. 8 + 9 + ## Definition of Done 10 + 11 + - Users can configure the relay URL before beginning onboarding 12 + - The app ships with a default production relay URL pre-filled 13 + - The configured URL is persisted across app restarts 14 + - The app verifies the relay is reachable before accepting the URL 15 + - Returning users (URL already saved) skip the configuration screen entirely 16 + 17 + ## Acceptance Criteria 18 + 19 + ### relay-url-config.AC1: Relay config screen shown on first launch 20 + - **relay-url-config.AC1.1 Success:** On first launch (no saved relay URL), the relay config screen appears before the welcome screen 21 + - **relay-url-config.AC1.2 Success:** User can accept the pre-filled default URL and proceed to welcome 22 + - **relay-url-config.AC1.3 Success:** User can enter a custom URL and proceed if the relay is healthy 23 + - **relay-url-config.AC1.4 Failure:** User cannot advance past the config screen without a valid, reachable URL 24 + 25 + ### relay-url-config.AC2: Default URL pre-filled 26 + - **relay-url-config.AC2.1 Success:** URL input is pre-filled with `https://relay.ezpds.com` on first launch 27 + 28 + ### relay-url-config.AC3: URL persists across restarts 29 + - **relay-url-config.AC3.1 Success:** After saving a URL and relaunching the app, the relay config screen is not shown 30 + - **relay-url-config.AC3.2 Success:** All relay IPC commands on subsequent launches use the saved URL 31 + 32 + ### relay-url-config.AC4: Relay reachability verified before saving 33 + - **relay-url-config.AC4.1 Success:** A URL whose `/xrpc/_health` returns HTTP 200 is accepted 34 + - **relay-url-config.AC4.2 Failure:** An unreachable host surfaces an `UNREACHABLE` inline error 35 + - **relay-url-config.AC4.3 Failure:** A malformed URL (not `http`/`https`, empty host) surfaces an `INVALID_URL` error before any network call 36 + - **relay-url-config.AC4.4 Edge:** A URL with a trailing slash is accepted and normalized (slash stripped) before saving 37 + 38 + ### relay-url-config.AC5: Returning users skip config screen 39 + - **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) 40 + - **relay-url-config.AC5.2 Edge:** The saved URL is used for relay calls on the same launch it was saved (no restart required) 41 + 42 + ### relay-url-config.AC6: Error and loading states 43 + - **relay-url-config.AC6.1 Success:** A loading/spinner state is shown while the health check is in flight 44 + - **relay-url-config.AC6.2 Failure:** `INVALID_URL` error is shown inline on the config screen (user stays on screen) 45 + - **relay-url-config.AC6.3 Failure:** `UNREACHABLE` error is shown inline on the config screen (user stays on screen) 46 + 47 + ## Glossary 48 + 49 + - **Relay**: The `ezpds` backend service (`crates/relay/`) that the identity wallet communicates with. Acts as a server-side intermediary for ATProto operations such as account creation and DID management. 50 + - **IPC command**: A named function exposed by the Tauri Rust backend and callable from the SvelteKit frontend via `window.__TAURI__.invoke()`. All IPC calls in this project are wrapped in typed functions in `src/lib/ipc.ts`. 51 + - **AppState**: A Rust struct shared across all Tauri IPC command handlers via Tauri's managed state mechanism. Acts as the single source of runtime state (relay client, OAuth session, pending auth). 52 + - **`OnceLock`**: A Rust standard-library type that holds a value that can be written exactly once and then read many times concurrently. Used here so `RelayClient` can be initialized from either Keychain at startup or from the `save_relay_url` command, whichever happens first. 53 + - **`RelayClient`**: The Rust struct in `http.rs` that wraps all outbound HTTP calls from the wallet to the relay, scoped to a single base URL. 54 + - **Keychain**: iOS's system-provided secure credential store. This project uses it (via `keychain.rs`) to persist all non-sensitive app configuration and credentials across restarts, including the relay URL. 55 + - **`/xrpc/_health`**: A conventional health-check endpoint on ATProto services. Returns HTTP 200 when the service is running and reachable. Used here to validate a candidate relay URL before saving it. 56 + - **`OnceLock::set()` idempotency**: `OnceLock::set()` silently discards a second write rather than panicking or overwriting. The design relies on this behavior to safely ignore any redundant initialization call. 57 + - **Onboarding step / step renderer**: The pattern in `+page.svelte` where the current screen is tracked as a string variable (`step`) and a conditional block renders the matching Svelte component. Each onboarding screen is a component in `src/lib/components/onboarding/`. 58 + - **ATProto (AT Protocol)**: The open federated social protocol developed by Bluesky. This project implements a personal data server (PDS) and identity wallet on top of it. 59 + - **Tauri**: A Rust-based framework for building desktop and mobile apps with a web frontend. Provides the bridge between the SvelteKit UI and the Rust backend, including the IPC mechanism. 60 + - **SvelteKit**: The fullstack web framework used for the wallet's frontend. Runs inside Tauri's webview. 61 + - **Compile-time constant / static singleton**: A value baked into the binary at build time. The existing `RELAY_CLIENT` global is such a constant; this design replaces it with a runtime-configurable instance. 62 + 63 + --- 64 + 65 + ## Architecture 66 + 67 + One-time relay URL configuration screen inserted before the existing onboarding flow. On first launch the user sees a URL input pre-filled with the production relay URL; they can accept it or enter their own. The app pings the relay's health endpoint before saving. On every subsequent launch the saved URL is loaded from Keychain and the screen is skipped. 68 + 69 + The relay URL threads through the Rust backend via `AppState`. `RelayClient` changes from a compile-time static singleton to an instance initialized at runtime with the configured URL. `AppState` gains a `relay_client: OnceLock<RelayClient>` field — set from Keychain during app startup for returning users, or set by the `save_relay_url` IPC command on first launch. 70 + 71 + **Frontend flow:** 72 + 73 + ``` 74 + On mount: 75 + getRelayUrl() → null → show relay_config step 76 + getRelayUrl() → string → skip to welcome step 77 + 78 + relay_config step: 79 + user edits URL (pre-filled with "https://relay.ezpds.com") 80 + user taps Connect 81 + → checkRelayHealth(url) [shows spinner] 82 + → on failure: inline error, stay on screen 83 + → on success: saveRelayUrl(url) → advance to welcome 84 + ``` 85 + 86 + **Rust initialization path:** 87 + 88 + ``` 89 + run() setup: 90 + keychain::get_item("relay-base-url") 91 + → Some(url): state.set_relay_client(url) 92 + → None: relay_client stays unset (get_or_init uses compile-time default as fallback) 93 + 94 + save_relay_url command: 95 + validate URL format 96 + ping GET /xrpc/_health 97 + keychain::store_item("relay-base-url", url) 98 + state.set_relay_client(url) 99 + ``` 100 + 101 + ## Existing Patterns 102 + 103 + Investigation found the following patterns this design follows: 104 + 105 + - **Keychain for persistence** — all persistent non-sensitive config in this app lives in the iOS Keychain under `keychain::SERVICE = "ezpds-identity-wallet"`. The relay URL follows this pattern, stored under a new `"relay-base-url"` account key. New keys are added as string constants in `keychain.rs`. 106 + - **AppState for shared runtime state** — `oauth::AppState` is the existing mechanism for sharing mutable runtime state across Tauri commands (pending auth, OAuth session). Adding `relay_client: OnceLock<RelayClient>` to this struct follows the established pattern. 107 + - **Typed IPC error codes** — all IPC commands return errors as `{ code: "SCREAMING_SNAKE_CASE" }`. The new `check_relay_health` and `save_relay_url` commands follow this convention. 108 + - **`ipc.ts` as the IPC boundary** — frontend never calls `invoke()` directly; all commands are wrapped in typed functions in `src/lib/ipc.ts`. New commands get wrappers there. 109 + - **Onboarding screen component pattern** — each step in `+page.svelte` corresponds to a component in `src/lib/components/onboarding/` that receives `onnext` / `onerror` callbacks and manages its own loading state. 110 + - **Health endpoint reuse** — `GET /xrpc/_health` is already used in `home.rs` to check relay reachability. The new `check_relay_health` command makes the same call against a caller-supplied URL. 111 + 112 + This design diverges from one existing decision: the CLAUDE.md documents "Compile-time relay URL" as an intentional choice. This design supersedes that decision — the compile-time constant becomes the default fallback only, with runtime configuration taking precedence. 113 + 114 + ## Implementation Phases 115 + 116 + <!-- START_PHASE_1 --> 117 + ### Phase 1: RelayClient runtime URL support 118 + 119 + **Goal:** Make `RelayClient` accept a runtime URL instead of the compile-time constant. 120 + 121 + **Components:** 122 + - `apps/identity-wallet/src-tauri/src/http.rs` — change `base_url: &'static str` to `base_url: String`; add `RelayClient::new_with_url(url: String) -> Self`; change `base_url()` from a `const fn` static method to an instance method `fn base_url(&self) -> &str`; keep `RelayClient::new()` using the compile-time default 123 + 124 + **Dependencies:** None 125 + 126 + **Done when:** `cargo build` succeeds with the updated `RelayClient`; all existing `http.rs` unit tests pass 127 + <!-- END_PHASE_1 --> 128 + 129 + <!-- START_PHASE_2 --> 130 + ### Phase 2: AppState integration and command migration 131 + 132 + **Goal:** Remove the `RELAY_CLIENT` global static and route all relay access through `AppState`. 133 + 134 + **Components:** 135 + - `apps/identity-wallet/src-tauri/src/oauth.rs` — add `relay_client: OnceLock<http::RelayClient>` to `AppState`; add `relay_client(&self) -> &RelayClient` (uses `get_or_init(RelayClient::new)` as fallback) and `set_relay_client(&self, url: String)` methods 136 + - `apps/identity-wallet/src-tauri/src/lib.rs` — remove `static RELAY_CLIENT`; add `state: State<AppState>` to all commands that currently call `RELAY_CLIENT` directly (`create_account`, `perform_did_ceremony`, `register_handle`, `check_handle_resolution`); replace `RELAY_CLIENT.xxx()` calls with `state.relay_client().xxx()`; also update the call to `http::RelayClient::base_url()` in `perform_did_ceremony` (line 379) to use the instance method 137 + - `apps/identity-wallet/src-tauri/src/oauth.rs` — replace `RelayClient::base_url()` static calls with `state.relay_client().base_url()`; `start_oauth_flow` already takes `State<AppState>` 138 + - `apps/identity-wallet/src-tauri/src/oauth_client.rs` — `OAuthClient::new()` currently calls `RelayClient::base_url()` static; change to accept the URL as a parameter; update all call sites in `oauth.rs` and `home.rs` to pass `state.relay_client().base_url()` 139 + 140 + **Dependencies:** Phase 1 141 + 142 + **Done when:** `cargo build` succeeds; all existing tests in `oauth_client.rs` and `lib.rs` pass; no references to `RELAY_CLIENT` remain in the codebase 143 + <!-- END_PHASE_2 --> 144 + 145 + <!-- START_PHASE_3 --> 146 + ### Phase 3: Relay URL IPC commands and startup initialization 147 + 148 + **Goal:** Expose relay URL configuration to the frontend and initialize the client from Keychain on startup. 149 + 150 + **Components:** 151 + - `apps/identity-wallet/src-tauri/src/keychain.rs` — add `"relay-base-url"` account constant; add `get_relay_url() -> Option<String>` and `store_relay_url(url: &str)` helpers 152 + - `apps/identity-wallet/src-tauri/src/lib.rs` — in `run()` setup block, read relay URL from Keychain and call `state.set_relay_client(url)` if found; add three new Tauri IPC commands: `get_relay_url() -> Option<String>`, `check_relay_health(url: String) -> Result<(), RelayConfigError>`, `save_relay_url(url: String) -> Result<(), RelayConfigError>`; register them in `invoke_handler` 153 + - New `RelayConfigError` type with variants `InvalidUrl` and `Unreachable`, serialized as `{ code: "INVALID_URL" | "UNREACHABLE" }` 154 + - URL validation: must parse as HTTP or HTTPS with a non-empty host; strip trailing slash before saving 155 + - Health check: `GET {url}/xrpc/_health` — any 200 response is accepted 156 + 157 + **Dependencies:** Phase 2 158 + 159 + **Done when:** `get_relay_url` returns `None` on first call and the stored URL on subsequent calls; `check_relay_health` returns `UNREACHABLE` for a non-existent host and succeeds for a live relay; `save_relay_url` persists to Keychain and initializes the relay client; tests cover success, `INVALID_URL`, and `UNREACHABLE` cases 160 + <!-- END_PHASE_3 --> 161 + 162 + <!-- START_PHASE_4 --> 163 + ### Phase 4: Frontend relay configuration screen 164 + 165 + **Goal:** Show the relay URL screen on first launch; skip it on return visits. 166 + 167 + **Components:** 168 + - `apps/identity-wallet/src/lib/ipc.ts` — add `getRelayUrl(): Promise<string | null>`, `checkRelayHealth(url: string): Promise<void>`, `saveRelayUrl(url: string): Promise<void>` with `RelayConfigError` type (`{ code: 'INVALID_URL' | 'UNREACHABLE' }`) 169 + - `apps/identity-wallet/src/lib/components/onboarding/RelayConfigScreen.svelte` — URL text input pre-filled with `"https://relay.ezpds.com"`; Connect button; loading state during health check; inline error display for `INVALID_URL` and `UNREACHABLE`; `onnext` callback on success 170 + - `apps/identity-wallet/src/routes/+page.svelte` — add `relay_config` as the initial step; on mount call `getRelayUrl()`: if non-null advance directly to `welcome`; if null stay on `relay_config`; add `relay_config` case to the step renderer 171 + 172 + **Dependencies:** Phase 3 173 + 174 + **Done when:** Fresh-state app (no saved URL) shows the relay configuration screen first; app with a saved URL skips directly to the welcome screen; invalid URL shows `INVALID_URL` error inline; unreachable host shows `UNREACHABLE` error inline; successful configuration advances to welcome 175 + <!-- END_PHASE_4 --> 176 + 177 + ## Additional Considerations 178 + 179 + **Trailing slash normalization:** Strip trailing slashes from the URL before saving and before constructing request paths (`url.trim_end_matches('/')`). This prevents double-slash paths like `https://example.com//xrpc/_health`. 180 + 181 + **`OnceLock::set()` idempotency:** `set()` silently drops a second call. `save_relay_url` is only reachable on first launch (subsequent launches skip the screen), so double-initialization is not a real-world concern — the silent-drop behavior is still the correct choice. 182 + 183 + **`oauth_client.rs` tests:** The existing test suite uses `OAuthClient::new_for_test(keypair, session, base_url)`, which already accepts a URL string. Changing `OAuthClient::new()` to accept a URL string does not affect test code.