···55pub mod keychain;
66pub mod oauth;
77pub mod oauth_client;
88+pub mod pds_client;
89910use crypto::{build_did_plc_genesis_op_with_external_signer, CryptoError, DidKeyUri};
1011use serde::{Deserialize, Serialize};
+205
apps/identity-wallet/src-tauri/src/pds_client.rs
···11+// pattern: Imperative Shell
22+//
33+// Gathers: PDS discovery parameters (handle, DID, OAuth metadata)
44+// Processes: DNS TXT resolution, HTTP well-known fetches, PDS OAuth metadata discovery
55+// Returns: PDS endpoints, authorization server metadata, or error codes
66+77+use std::collections::HashMap;
88+99+use reqwest::Client;
1010+use serde::{Deserialize, Serialize};
1111+1212+/// Error type for PDS client operations.
1313+///
1414+/// Serializes to frontend with `#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]`,
1515+/// matching the `OAuthError` / `IdentityStoreError` pattern.
1616+#[derive(Debug, Serialize)]
1717+#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]
1818+pub enum PdsClientError {
1919+ /// Neither DNS nor HTTP resolution succeeded for the handle.
2020+ HandleNotFound,
2121+2222+ /// plc.directory returned 404 for the DID.
2323+ DidNotFound,
2424+2525+ /// PDS endpoint is down or unreachable.
2626+ PdsUnreachable {
2727+ /// Reason for unreachability (transport error, connection refused, etc.).
2828+ /// Not serialized to frontend (serde skip).
2929+ #[serde(skip)]
3030+ source: String,
3131+ },
3232+3333+ /// Transport-level failure (DNS timeout, connection refused, etc.).
3434+ NetworkError { message: String },
3535+3636+ /// Response body couldn't be parsed or was missing expected fields.
3737+ InvalidResponse { message: String },
3838+3939+ /// PAR or token exchange failed.
4040+ OAuthFailed { message: String },
4141+}
4242+4343+impl std::fmt::Display for PdsClientError {
4444+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4545+ match self {
4646+ Self::HandleNotFound => write!(f, "handle not found"),
4747+ Self::DidNotFound => write!(f, "did not found"),
4848+ Self::PdsUnreachable { source } => write!(f, "pds unreachable: {}", source),
4949+ Self::NetworkError { message } => write!(f, "network error: {}", message),
5050+ Self::InvalidResponse { message } => write!(f, "invalid response: {}", message),
5151+ Self::OAuthFailed { message } => write!(f, "oauth failed: {}", message),
5252+ }
5353+ }
5454+}
5555+5656+impl std::error::Error for PdsClientError {}
5757+5858+/// PLC directory DID document response.
5959+///
6060+/// Returned from `GET {plc_directory_url}/{did}`.
6161+/// Field names use camelCase per the API.
6262+#[derive(Debug, Deserialize)]
6363+#[serde(rename_all = "camelCase")]
6464+pub struct PlcDidDocument {
6565+ pub did: String,
6666+ pub also_known_as: Vec<String>,
6767+ pub rotation_keys: Vec<String>,
6868+ pub verification_methods: serde_json::Value,
6969+ pub services: HashMap<String, PlcService>,
7070+}
7171+7272+/// PLC service entry (one service in `PlcDidDocument.services`).
7373+#[derive(Debug, Deserialize)]
7474+pub struct PlcService {
7575+ #[serde(rename = "type")]
7676+ pub service_type: String,
7777+ pub endpoint: String,
7878+}
7979+8080+/// OAuth authorization server metadata.
8181+///
8282+/// Returned from `GET {pds_url}/.well-known/oauth-authorization-server`.
8383+#[derive(Debug, Deserialize)]
8484+pub struct AuthServerMetadata {
8585+ pub issuer: String,
8686+ pub authorization_endpoint: String,
8787+ pub token_endpoint: String,
8888+ pub pushed_authorization_request_endpoint: Option<String>,
8989+ pub response_types_supported: Vec<String>,
9090+ pub grant_types_supported: Vec<String>,
9191+ pub code_challenge_methods_supported: Vec<String>,
9292+ pub dpop_signing_alg_values_supported: Option<Vec<String>>,
9393+ pub scopes_supported: Option<Vec<String>>,
9494+}
9595+9696+/// Response from PAR (Pushed Authorization Request).
9797+///
9898+/// Returned from `POST {pushed_authorization_request_endpoint}`.
9999+#[derive(Debug, Deserialize)]
100100+pub struct PdsParResponse {
101101+ pub request_uri: String,
102102+ pub expires_in: u32,
103103+}
104104+105105+/// Request body for `signPlcOperation`.
106106+///
107107+/// Serializes to frontend with `#[serde(rename_all = "camelCase")]`.
108108+/// Optional fields are skipped if None.
109109+#[derive(Debug, Serialize)]
110110+#[serde(rename_all = "camelCase")]
111111+pub struct SignPlcOperationRequest {
112112+ pub token: String,
113113+ #[serde(skip_serializing_if = "Option::is_none")]
114114+ pub rotation_keys: Option<Vec<String>>,
115115+ #[serde(skip_serializing_if = "Option::is_none")]
116116+ pub also_known_as: Option<Vec<String>>,
117117+ #[serde(skip_serializing_if = "Option::is_none")]
118118+ pub verification_methods: Option<serde_json::Value>,
119119+ #[serde(skip_serializing_if = "Option::is_none")]
120120+ pub services: Option<serde_json::Value>,
121121+}
122122+123123+/// Response from `signPlcOperation`.
124124+///
125125+/// Returned from `POST /xrpc/com.atproto.identity.signPlcOperation`.
126126+#[derive(Debug, Deserialize)]
127127+pub struct SignPlcOperationResponse {
128128+ pub operation: serde_json::Value,
129129+}
130130+131131+/// Recommended credentials for a DID.
132132+///
133133+/// Returned from `GET /xrpc/com.atproto.identity.getRecommendedDidCredentials`.
134134+#[derive(Debug, Deserialize)]
135135+#[serde(rename_all = "camelCase")]
136136+pub struct RecommendedCredentials {
137137+ pub rotation_keys: Option<Vec<String>>,
138138+ pub also_known_as: Option<Vec<String>>,
139139+ pub verification_methods: Option<serde_json::Value>,
140140+ pub services: Option<serde_json::Value>,
141141+}
142142+143143+/// PDS client for discovery and OAuth operations against arbitrary PDS endpoints.
144144+///
145145+/// Stateless except for the HTTP client which pools connections.
146146+#[allow(dead_code)]
147147+pub struct PdsClient {
148148+ client: Client,
149149+ plc_directory_url: String,
150150+}
151151+152152+impl PdsClient {
153153+ /// Construct a new PdsClient with the default plc.directory URL.
154154+ pub fn new() -> Self {
155155+ Self {
156156+ client: Client::new(),
157157+ plc_directory_url: "https://plc.directory".to_string(),
158158+ }
159159+ }
160160+161161+ /// Test constructor: accepts a custom plc.directory URL (e.g., mock server).
162162+ ///
163163+ /// Follows the same pattern as `OAuthClient::new_for_test` in oauth_client.rs.
164164+ #[cfg(test)]
165165+ pub fn new_for_test(plc_directory_url: String) -> Self {
166166+ Self {
167167+ client: Client::new(),
168168+ plc_directory_url,
169169+ }
170170+ }
171171+}
172172+173173+impl Default for PdsClient {
174174+ fn default() -> Self {
175175+ Self::new()
176176+ }
177177+}
178178+179179+#[cfg(test)]
180180+mod tests {
181181+ use super::*;
182182+183183+ #[test]
184184+ fn test_pds_client_default() {
185185+ let client = PdsClient::default();
186186+ assert_eq!(client.plc_directory_url, "https://plc.directory");
187187+ }
188188+189189+ #[test]
190190+ fn test_sign_plc_operation_request_skip_none_fields() {
191191+ let req = SignPlcOperationRequest {
192192+ token: "test_token".to_string(),
193193+ rotation_keys: None,
194194+ also_known_as: None,
195195+ verification_methods: None,
196196+ services: None,
197197+ };
198198+199199+ let json = serde_json::to_string(&req).expect("serialization failed");
200200+ // Verify that None fields are skipped
201201+ assert!(!json.contains("rotationKeys"));
202202+ assert!(!json.contains("alsoKnownAs"));
203203+ assert!(json.contains("token"));
204204+ }
205205+}