An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): add pds_client module skeleton with error and response types

authored by

Malpercio and committed by
Tangled
1d770dd4 5849e478

+207
+1
apps/identity-wallet/src-tauri/Cargo.toml
··· 35 35 rand_core = { workspace = true } 36 36 uuid = { workspace = true } 37 37 tokio = { workspace = true } 38 + hickory-resolver = { workspace = true } 38 39 zeroize = { workspace = true } 39 40 40 41 [dev-dependencies]
+1
apps/identity-wallet/src-tauri/src/lib.rs
··· 5 5 pub mod keychain; 6 6 pub mod oauth; 7 7 pub mod oauth_client; 8 + pub mod pds_client; 8 9 9 10 use crypto::{build_did_plc_genesis_op_with_external_signer, CryptoError, DidKeyUri}; 10 11 use serde::{Deserialize, Serialize};
+205
apps/identity-wallet/src-tauri/src/pds_client.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: PDS discovery parameters (handle, DID, OAuth metadata) 4 + // Processes: DNS TXT resolution, HTTP well-known fetches, PDS OAuth metadata discovery 5 + // Returns: PDS endpoints, authorization server metadata, or error codes 6 + 7 + use std::collections::HashMap; 8 + 9 + use reqwest::Client; 10 + use serde::{Deserialize, Serialize}; 11 + 12 + /// Error type for PDS client operations. 13 + /// 14 + /// Serializes to frontend with `#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]`, 15 + /// matching the `OAuthError` / `IdentityStoreError` pattern. 16 + #[derive(Debug, Serialize)] 17 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 18 + pub enum PdsClientError { 19 + /// Neither DNS nor HTTP resolution succeeded for the handle. 20 + HandleNotFound, 21 + 22 + /// plc.directory returned 404 for the DID. 23 + DidNotFound, 24 + 25 + /// PDS endpoint is down or unreachable. 26 + PdsUnreachable { 27 + /// Reason for unreachability (transport error, connection refused, etc.). 28 + /// Not serialized to frontend (serde skip). 29 + #[serde(skip)] 30 + source: String, 31 + }, 32 + 33 + /// Transport-level failure (DNS timeout, connection refused, etc.). 34 + NetworkError { message: String }, 35 + 36 + /// Response body couldn't be parsed or was missing expected fields. 37 + InvalidResponse { message: String }, 38 + 39 + /// PAR or token exchange failed. 40 + OAuthFailed { message: String }, 41 + } 42 + 43 + impl std::fmt::Display for PdsClientError { 44 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 45 + match self { 46 + Self::HandleNotFound => write!(f, "handle not found"), 47 + Self::DidNotFound => write!(f, "did not found"), 48 + Self::PdsUnreachable { source } => write!(f, "pds unreachable: {}", source), 49 + Self::NetworkError { message } => write!(f, "network error: {}", message), 50 + Self::InvalidResponse { message } => write!(f, "invalid response: {}", message), 51 + Self::OAuthFailed { message } => write!(f, "oauth failed: {}", message), 52 + } 53 + } 54 + } 55 + 56 + impl std::error::Error for PdsClientError {} 57 + 58 + /// PLC directory DID document response. 59 + /// 60 + /// Returned from `GET {plc_directory_url}/{did}`. 61 + /// Field names use camelCase per the API. 62 + #[derive(Debug, Deserialize)] 63 + #[serde(rename_all = "camelCase")] 64 + pub struct PlcDidDocument { 65 + pub did: String, 66 + pub also_known_as: Vec<String>, 67 + pub rotation_keys: Vec<String>, 68 + pub verification_methods: serde_json::Value, 69 + pub services: HashMap<String, PlcService>, 70 + } 71 + 72 + /// PLC service entry (one service in `PlcDidDocument.services`). 73 + #[derive(Debug, Deserialize)] 74 + pub struct PlcService { 75 + #[serde(rename = "type")] 76 + pub service_type: String, 77 + pub endpoint: String, 78 + } 79 + 80 + /// OAuth authorization server metadata. 81 + /// 82 + /// Returned from `GET {pds_url}/.well-known/oauth-authorization-server`. 83 + #[derive(Debug, Deserialize)] 84 + pub struct AuthServerMetadata { 85 + pub issuer: String, 86 + pub authorization_endpoint: String, 87 + pub token_endpoint: String, 88 + pub pushed_authorization_request_endpoint: Option<String>, 89 + pub response_types_supported: Vec<String>, 90 + pub grant_types_supported: Vec<String>, 91 + pub code_challenge_methods_supported: Vec<String>, 92 + pub dpop_signing_alg_values_supported: Option<Vec<String>>, 93 + pub scopes_supported: Option<Vec<String>>, 94 + } 95 + 96 + /// Response from PAR (Pushed Authorization Request). 97 + /// 98 + /// Returned from `POST {pushed_authorization_request_endpoint}`. 99 + #[derive(Debug, Deserialize)] 100 + pub struct PdsParResponse { 101 + pub request_uri: String, 102 + pub expires_in: u32, 103 + } 104 + 105 + /// Request body for `signPlcOperation`. 106 + /// 107 + /// Serializes to frontend with `#[serde(rename_all = "camelCase")]`. 108 + /// Optional fields are skipped if None. 109 + #[derive(Debug, Serialize)] 110 + #[serde(rename_all = "camelCase")] 111 + pub struct SignPlcOperationRequest { 112 + pub token: String, 113 + #[serde(skip_serializing_if = "Option::is_none")] 114 + pub rotation_keys: Option<Vec<String>>, 115 + #[serde(skip_serializing_if = "Option::is_none")] 116 + pub also_known_as: Option<Vec<String>>, 117 + #[serde(skip_serializing_if = "Option::is_none")] 118 + pub verification_methods: Option<serde_json::Value>, 119 + #[serde(skip_serializing_if = "Option::is_none")] 120 + pub services: Option<serde_json::Value>, 121 + } 122 + 123 + /// Response from `signPlcOperation`. 124 + /// 125 + /// Returned from `POST /xrpc/com.atproto.identity.signPlcOperation`. 126 + #[derive(Debug, Deserialize)] 127 + pub struct SignPlcOperationResponse { 128 + pub operation: serde_json::Value, 129 + } 130 + 131 + /// Recommended credentials for a DID. 132 + /// 133 + /// Returned from `GET /xrpc/com.atproto.identity.getRecommendedDidCredentials`. 134 + #[derive(Debug, Deserialize)] 135 + #[serde(rename_all = "camelCase")] 136 + pub struct RecommendedCredentials { 137 + pub rotation_keys: Option<Vec<String>>, 138 + pub also_known_as: Option<Vec<String>>, 139 + pub verification_methods: Option<serde_json::Value>, 140 + pub services: Option<serde_json::Value>, 141 + } 142 + 143 + /// PDS client for discovery and OAuth operations against arbitrary PDS endpoints. 144 + /// 145 + /// Stateless except for the HTTP client which pools connections. 146 + #[allow(dead_code)] 147 + pub struct PdsClient { 148 + client: Client, 149 + plc_directory_url: String, 150 + } 151 + 152 + impl PdsClient { 153 + /// Construct a new PdsClient with the default plc.directory URL. 154 + pub fn new() -> Self { 155 + Self { 156 + client: Client::new(), 157 + plc_directory_url: "https://plc.directory".to_string(), 158 + } 159 + } 160 + 161 + /// Test constructor: accepts a custom plc.directory URL (e.g., mock server). 162 + /// 163 + /// Follows the same pattern as `OAuthClient::new_for_test` in oauth_client.rs. 164 + #[cfg(test)] 165 + pub fn new_for_test(plc_directory_url: String) -> Self { 166 + Self { 167 + client: Client::new(), 168 + plc_directory_url, 169 + } 170 + } 171 + } 172 + 173 + impl Default for PdsClient { 174 + fn default() -> Self { 175 + Self::new() 176 + } 177 + } 178 + 179 + #[cfg(test)] 180 + mod tests { 181 + use super::*; 182 + 183 + #[test] 184 + fn test_pds_client_default() { 185 + let client = PdsClient::default(); 186 + assert_eq!(client.plc_directory_url, "https://plc.directory"); 187 + } 188 + 189 + #[test] 190 + fn test_sign_plc_operation_request_skip_none_fields() { 191 + let req = SignPlcOperationRequest { 192 + token: "test_token".to_string(), 193 + rotation_keys: None, 194 + also_known_as: None, 195 + verification_methods: None, 196 + services: None, 197 + }; 198 + 199 + let json = serde_json::to_string(&req).expect("serialization failed"); 200 + // Verify that None fields are skipped 201 + assert!(!json.contains("rotationKeys")); 202 + assert!(!json.contains("alsoKnownAs")); 203 + assert!(json.contains("token")); 204 + } 205 + }