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: update identity-wallet CLAUDE.md for PdsClient module

Document the new pds_client.rs module added in Phase 3 (PDS Discovery
& OAuth to Arbitrary PDS). Covers PdsClient struct and public API,
PdsClientError variants, XRPC identity functions, AppState.pds_client
field, new dependencies (hickory-resolver, urlencoding), and key
architectural decisions (PdsClient vs RelayClient separation, XRPC
functions as module-level rather than methods).

authored by

Malpercio and committed by
Tangled
2dd61a79 497082e1

+21 -1
+21 -1
apps/identity-wallet/CLAUDE.md
··· 1 1 # Identity Wallet Mobile App 2 2 3 3 Last verified: 2026-03-28 4 + Last updated: 2026-03-28 4 5 5 6 ## Purpose 6 7 ··· 34 35 - `src/lib.rs::sign_with_device_key(data: Vec<u8>) -> Result<Vec<u8>, DeviceKeyError>` — Tauri IPC command: delegates to `device_key::sign()` 35 36 - `src/lib.rs::perform_did_ceremony(handle: String, password: String) -> Result<DIDCeremonyResult, DIDCeremonyError>` — Tauri IPC command: fetches relay signing key (GET /v1/relay/keys), builds signed did:plc genesis op via `crypto::build_did_plc_genesis_op_with_external_signer` using device key as signer, POSTs genesis op + password to relay (POST /v1/dids with Bearer token), persists DID + upgraded session token + Share 1 in Keychain, returns `{ did, share3 }` to frontend 36 37 - `src/home.rs` — Home screen data module: `load_home_data(AppState) -> Result<HomeData, String>` (Tauri IPC command: fires GET /xrpc/_health and GET /xrpc/com.atproto.server.getSession concurrently via OAuthClient; always succeeds -- partial failures encoded as HomeData fields); `log_out(AppState) -> Result<(), String>` (Tauri IPC command: deletes oauth-access-token, oauth-refresh-token, and did from Keychain, clears in-memory oauth_session; always succeeds -- Keychain errors swallowed); output types: `HomeData` { relay_healthy, session, session_error, share1_in_keychain }, `SessionInfo` { did, handle, email, email_confirmed, did_doc } 37 - - `src/oauth.rs` — OAuth PKCE client module: `AppState` (pending_auth + oauth_session mutexes), `OAuthSession` (access/refresh/expiry/nonce), `DPoPKeypair` (P-256, persisted in Keychain), `OAuthError` enum, PKCE utilities (verifier + S256 challenge), `start_oauth_flow` (Tauri IPC command: DPoP keygen, PKCE, PAR, Safari redirect, deep-link callback, token exchange), `handle_deep_link` (routes deep-link URLs to pending flow) 38 + - `src/oauth.rs` — OAuth PKCE client module: `AppState` (pending_auth + oauth_session mutexes + relay_client OnceLock + pds_client), `OAuthSession` (access/refresh/expiry/nonce), `DPoPKeypair` (P-256, persisted in Keychain), `OAuthError` enum, PKCE utilities (verifier + S256 challenge), `start_oauth_flow` (Tauri IPC command: DPoP keygen, PKCE, PAR, Safari redirect, deep-link callback, token exchange), `handle_deep_link` (routes deep-link URLs to pending flow); `AppState::pds_client()` accessor exposes `PdsClient` for Phase 4 Tauri commands 38 39 - `src/oauth_client.rs` — `OAuthClient`: authenticated HTTP client wrapping every request with `Authorization: DPoP {access_token}` + `DPoP` proof headers; transparent lazy refresh when token has <60s remaining; automatic retry on `use_dpop_nonce` 400 responses; methods: `get(path)`, `post(path, body)` 39 40 - `src/device_key.rs` — P-256 device key management with `#[cfg]`-based dispatch: macOS/simulator uses software keys via `crypto` crate + Keychain storage; real iOS device uses Secure Enclave via `security-framework`. Public API: `get_or_create() -> Result<DevicePublicKey, DeviceKeyError>` (idempotent), `sign(data) -> Result<Vec<u8>, DeviceKeyError>` 40 41 - `src/keychain.rs` — iOS Keychain abstraction (`store_item`, `get_item`, `delete_item`) under service `"ezpds-identity-wallet"`; Relay URL helpers: `store_relay_url`/`load_relay_url` (relay base URL); OAuth helpers: `store_dpop_key`/`load_dpop_key` (P-256 DPoP private key scalar), `store_oauth_tokens`/`load_oauth_tokens` (access + refresh token pair) 41 42 - `src/http.rs` — `RelayClient` with runtime-configurable base URL (initialized via `AppState::set_relay_client(url)` on first launch; localhost:8080 debug fallback); methods: `post()`, `get()`, `post_with_bearer()`, `par()` (POST /oauth/par with DPoP proof), `token_exchange()` (POST /oauth/token with PKCE verifier); response types: `ParResponse`, `TokenResponse`, `TokenErrorResponse` 42 43 - `src/identity_store.rs` — `IdentityStore` unit struct for multi-identity Keychain management with per-DID namespacing. Public API: `add_identity(did)` (registers DID in managed-dids index), `remove_identity(did)` (deletes DID and all per-DID entries), `list_identities()` (returns managed DIDs), `get_or_create_device_key(did)` (lazy per-DID P-256 key generation), `store_did_doc(did, json)` / `get_did_doc(did)` (DID document persistence), `store_plc_log(did, json)` / `get_plc_log(did)` (PLC audit log persistence). All methods require DID to be registered first (returns `IdentityNotFound` otherwise). `IdentityStoreError` enum: IDENTITY_NOT_FOUND, IDENTITY_ALREADY_EXISTS, KEYCHAIN_ERROR, KEY_GENERATION_FAILED, SERIALIZATION_ERROR (serialized as `{ code: "SCREAMING_SNAKE_CASE" }`) 44 + - `src/pds_client.rs` — PDS discovery and OAuth module for arbitrary PDS endpoints (not just our relay). `PdsClient` struct (stateless, wraps a `reqwest::Client` + plc.directory URL). Public API: `resolve_handle(handle) -> Result<String, PdsClientError>` (DNS TXT `_atproto.{handle}` with HTTP `/.well-known/atproto-did` fallback), `discover_pds(did) -> Result<(String, PlcDidDocument), PdsClientError>` (fetches DID doc from plc.directory, extracts `atproto_pds` endpoint, verifies reachability via HEAD), `discover_auth_server(pds_url) -> Result<AuthServerMetadata, PdsClientError>` (fetches `/.well-known/oauth-authorization-server`, validates `code` response type + S256 challenge method), `pds_par(metadata, pkce_challenge, state, dpop_proof, dpop_jkt, login_hint?) -> Result<PdsParResponse, PdsClientError>` (PAR to arbitrary PDS), `pds_token_exchange(metadata, code, pkce_verifier, dpop_proof) -> Result<reqwest::Response, PdsClientError>` (returns raw response for caller nonce-retry), `build_pds_authorize_url(metadata, request_uri, login_hint?) -> String` (constructs browser redirect URL). Module-level XRPC functions (take `&OAuthClient`): `request_plc_operation_signature(client)`, `sign_plc_operation(client, request)`, `get_recommended_did_credentials(client)`. Types: `PlcDidDocument`, `PlcService`, `AuthServerMetadata`, `PdsParResponse`, `SignPlcOperationRequest`, `SignPlcOperationResponse`, `RecommendedCredentials`. `PdsClientError` enum: HANDLE_NOT_FOUND, DID_NOT_FOUND, PDS_UNREACHABLE, NETWORK_ERROR, INVALID_RESPONSE, OAUTH_FAILED (serialized as `{ code: "SCREAMING_SNAKE_CASE" }`) 43 45 - `src/lib.rs::get_relay_url() -> Option<String>` — Tauri IPC command: loads relay base URL from Keychain, returns Some(url) if configured or None for first-launch 44 46 - `src/lib.rs::save_relay_url(url: String) -> Result<(), RelayConfigError>` — Tauri IPC command: validates URL format, pings `/xrpc/_health` on the relay, saves to Keychain, initializes `AppState.relay_client` (runtime configuration) 45 47 ··· 66 68 - `IdentityStore::remove_identity` performs best-effort cleanup of all six per-DID Keychain entries (device-key, device-key-pub, device-key-app-label, did-doc, plc-log, oauth-tokens); Keychain not-found errors during cleanup are ignored 67 69 - `IdentityStore::get_or_create_device_key` uses the same `#[cfg]` dispatch pattern as `device_key.rs` (software P-256 on macOS/simulator, Secure Enclave on real iOS) but with per-DID Keychain account namespacing (`"{did}:device-key"` instead of `"device-rotation-key-priv"`) 68 70 - `IdentityStoreError` variants serialize as `{ code: "SCREAMING_SNAKE_CASE" }` matching the `CreateAccountError` pattern 71 + - `PdsClientError` variants serialize as `{ code: "SCREAMING_SNAKE_CASE" }` matching the `CreateAccountError` pattern; the `PdsUnreachable` variant's `reason` field is `#[serde(skip)]` (not sent to frontend) 72 + - `PdsClient` is stateless (wraps `reqwest::Client` for connection pooling); default constructor targets `https://plc.directory`; test constructor accepts a custom URL for mock servers 73 + - `PdsClient` is initialized eagerly in `AppState::new()` (not OnceLock) because it is cheap and stateless 74 + - `pds_token_exchange` returns the raw `reqwest::Response` (not parsed) so callers can inspect `use_dpop_nonce` headers and implement retry logic 75 + - XRPC identity functions (`request_plc_operation_signature`, `sign_plc_operation`, `get_recommended_did_credentials`) are module-level functions (not methods on `PdsClient`) because they require a DPoP-authenticated `OAuthClient` rather than the stateless HTTP client 76 + - `resolve_handle` tries DNS TXT first (`_atproto.{handle}`), then HTTP `/.well-known/atproto-did`; returns `HANDLE_NOT_FOUND` only when both methods fail 77 + - `discover_pds` verifies PDS reachability with a HEAD request (5-second timeout) after extracting the `atproto_pds` service endpoint from the DID document 78 + - `discover_auth_server` validates that the OAuth metadata includes `"code"` in `response_types_supported` and `"S256"` in `code_challenge_methods_supported` 79 + - `PdsClient` OAuth client_id and redirect_uri are hardcoded as `"dev.malpercio.identitywallet"` and `"dev.malpercio.identitywallet:/oauth/callback"` -- must match `oauth.rs` constants and relay V013 migration 69 80 - Per-DID Keychain accounts use `"{did}:suffix"` format (e.g. `"did:plc:abc123:device-key"`) -- the colon separator is part of the naming convention 70 81 71 82 **Expects:** ··· 91 102 - Rust backend -> relay `GET /xrpc/com.atproto.server.getSession` endpoint (DPoP-authenticated via OAuthClient; fetches session info for home screen) 92 103 - Rust backend -> `tauri-plugin-deep-link` (registers `dev.malpercio.identitywallet:` URL scheme for OAuth callback) 93 104 - Rust backend -> `tauri-plugin-opener` (opens Safari for OAuth authorization) 105 + - Rust backend -> plc.directory (via `reqwest` HTTP at runtime; used by `PdsClient::discover_pds` to fetch DID documents) 106 + - Rust backend -> arbitrary PDS endpoints (via `reqwest` HTTP at runtime; used by `PdsClient` for OAuth discovery, PAR, token exchange, and XRPC identity methods) 107 + - Rust backend -> `hickory-resolver` (workspace dep: DNS TXT resolution for ATProto handle verification in `pds_client::try_resolve_dns`) 108 + - Rust backend -> `urlencoding` (local dep: URL-encoding for OAuth authorize URL construction in `PdsClient::build_pds_authorize_url`) 94 109 - Rust backend -> iOS Keychain (via `security-framework` crate with `OSX_10_12` feature for SE access control APIs) 95 110 - Rust backend -> Secure Enclave hardware (real iOS device only; via `security-framework` `SecKey`/`GenerateKeyOptions`/`Token::SecureEnclave`) 96 111 - `src-tauri/gen/` -> NOT tracked in git; generated per-developer by `cargo tauri ios init` (gitignored) ··· 214 229 - **Home screen data flow**: HomeScreen calls `loadHomeData()` on mount, stores the result in local state, and passes the full HomeData to child screens (DIDDocumentScreen, RecoveryInfoScreen) via the page-level state machine in `+page.svelte` rather than having children re-fetch. 215 230 - **Startup token restore**: On app launch, `lib.rs::run()` checks Keychain for persisted OAuth tokens. If found, restores them into `AppState.oauth_session` with `expires_at = 0` (forces immediate refresh on first use) and emits `auth_ready` after 300ms delay so SvelteKit has time to boot. 216 231 - **Per-DID Keychain namespacing (`identity_store.rs`)**: Multi-identity support uses DID-prefixed Keychain accounts (`"{did}:device-key"`, etc.) instead of the single-identity global accounts in `device_key.rs`. A top-level `"managed-dids"` JSON array index tracks all registered DIDs. Device keys are lazily generated on first `get_or_create_device_key` rather than at identity registration time. The module uses the same `#[cfg]` dispatch pattern as `device_key.rs` for software vs. SE key generation but with per-DID scoping. 232 + - **PDS client separate from relay client (`pds_client.rs`)**: `PdsClient` handles discovery and OAuth against arbitrary PDS endpoints (not just our relay), while `RelayClient` (in `http.rs`) handles communication with the user's configured relay. The separation exists because PDS discovery targets endpoints the wallet learns at runtime (plc.directory, user's PDS), whereas `RelayClient` targets a single configured relay. `PdsClient` is stateless and uses `reqwest::Client` directly; `RelayClient` holds a runtime-configured base URL. 233 + - **XRPC identity functions as module-level functions**: `request_plc_operation_signature`, `sign_plc_operation`, and `get_recommended_did_credentials` are standalone functions in `pds_client.rs` (not methods on `PdsClient`) because they require a DPoP-authenticated `OAuthClient` for the Authorization header, which `PdsClient`'s plain HTTP client cannot provide. This keeps `PdsClient` focused on unauthenticated discovery while XRPC calls use the existing `OAuthClient` infrastructure. 234 + - **DNS resolution via hickory-resolver**: Handle resolution uses `hickory-resolver` for DNS TXT lookups (`_atproto.{handle}`), matching the same DNS library used by the relay crate (`crates/relay/src/dns.rs`). Falls back to HTTP `/.well-known/atproto-did` when DNS fails. 217 235 218 236 ## Invariants 219 237 ··· 236 254 - Keychain account `"oauth-access-token"` stores the OAuth access token; written by `start_oauth_flow` on success and by `OAuthClient` on refresh 237 255 - Keychain account `"oauth-refresh-token"` stores the OAuth refresh token; written alongside the access token 238 256 - `OAuthError` variant names serialize as SCREAMING_SNAKE_CASE to the frontend -- the TypeScript `OAuthError.code` union must match exactly 257 + - `PdsClientError` variant names serialize as SCREAMING_SNAKE_CASE to the frontend -- the TypeScript `PdsClientError.code` union must match exactly (HANDLE_NOT_FOUND, DID_NOT_FOUND, PDS_UNREACHABLE, NETWORK_ERROR, INVALID_RESPONSE, OAUTH_FAILED) 239 258 - OAuth client_id is always `"dev.malpercio.identitywallet"` -- must match the seeded row in relay migration V013 and the `tauri.conf.json` bundle identifier 240 259 - OAuth redirect_uri is always `"dev.malpercio.identitywallet:/oauth/callback"` -- must match the deep-link scheme in `tauri.conf.json` and the seeded client_metadata redirect_uris in V013 241 260 - `DevicePublicKey` serializes with `#[serde(rename_all = "camelCase")]` -- TypeScript receives `{ multibase, keyId }` (not `key_id`) ··· 253 272 - `src-tauri/src/home.rs` -- Home screen Tauri commands: `load_home_data` (concurrent relay health + getSession), `log_out` (Keychain wipe + session clear); output types: HomeData, SessionInfo 254 273 - `src-tauri/src/device_key.rs` -- P-256 device key module: `#[cfg]`-dispatched `get_or_create()` and `sign()` (simulator software path vs. Secure Enclave) 255 274 - `src-tauri/src/identity_store.rs` -- Multi-identity Keychain management: IdentityStore (add/remove/list identities, per-DID device key generation, DID doc + PLC log persistence) 275 + - `src-tauri/src/pds_client.rs` -- PDS discovery and OAuth to arbitrary PDS: PdsClient (resolve_handle, discover_pds, discover_auth_server, pds_par, pds_token_exchange, build_pds_authorize_url); XRPC identity functions (request_plc_operation_signature, sign_plc_operation, get_recommended_did_credentials) 256 276 - `src-tauri/src/main.rs` -- Desktop entry point (calls `lib::run()`) 257 277 - `src-tauri/src/oauth.rs` -- OAuth PKCE module: AppState, DPoPKeypair, OAuthSession, PKCE utilities, start_oauth_flow command, handle_deep_link 258 278 - `src-tauri/src/oauth_client.rs` -- OAuthClient: authenticated HTTP client with DPoP proofs and lazy token refresh