1import 'package:dio/dio.dart'; 2 3import '../config/environment_config.dart'; 4 5/// PDS Discovery Service 6/// 7/// Handles the resolution of atProto handles to their Personal Data 8/// Servers (PDS). This is crucial for proper decentralized 9/// authentication - each user may be on a different PDS, and we need to 10/// redirect them to THEIR PDS's OAuth server. 11/// 12/// Flow: 13/// 1. Resolve handle to DID using a handle resolver 14/// 2. Fetch the DID document from the PLC directory 15/// 3. Extract the PDS endpoint from the service array 16/// 4. Return the PDS URL for OAuth discovery 17class PDSDiscoveryService { 18 PDSDiscoveryService({EnvironmentConfig? config}) 19 : _config = config ?? EnvironmentConfig.current; 20 21 final Dio _dio = Dio(); 22 final EnvironmentConfig _config; 23 24 /// Discover the PDS URL for a given atProto handle 25 /// 26 /// Example: 27 /// ```dart 28 /// final pds = await discoverPDS('bretton.dev'); 29 /// // Returns: 'https://pds.bretton.dev' 30 /// ``` 31 Future<String> discoverPDS(String handle) async { 32 try { 33 // Step 1: Resolve handle to DID 34 final did = await _resolveHandle(handle); 35 36 // Step 2: Fetch DID document 37 final didDoc = await _fetchDIDDocument(did); 38 39 // Step 3: Extract PDS endpoint 40 final pdsUrl = _extractPDSEndpoint(didDoc); 41 42 return pdsUrl; 43 } catch (e) { 44 throw Exception('Failed to discover PDS for $handle: $e'); 45 } 46 } 47 48 /// Resolve an atProto handle to a DID 49 /// 50 /// Uses configured handle resolver (production: Bluesky, local: your PDS) 51 Future<String> _resolveHandle(String handle) async { 52 try { 53 final response = await _dio.get( 54 _config.handleResolverUrl, 55 queryParameters: {'handle': handle}, 56 ); 57 58 if (response.statusCode != 200) { 59 throw Exception('Failed to resolve handle: ${response.statusCode}'); 60 } 61 62 final did = response.data['did'] as String?; 63 if (did == null) { 64 throw Exception('No DID found in response'); 65 } 66 67 return did; 68 } catch (e) { 69 throw Exception('Handle resolution failed: $e'); 70 } 71 } 72 73 /// Fetch a DID document from the PLC directory 74 Future<Map<String, dynamic>> _fetchDIDDocument(String did) async { 75 try { 76 final response = await _dio.get('${_config.plcDirectoryUrl}/$did'); 77 78 if (response.statusCode != 200) { 79 throw Exception('Failed to fetch DID document: ${response.statusCode}'); 80 } 81 82 return response.data as Map<String, dynamic>; 83 } catch (e) { 84 throw Exception('DID document fetch failed: $e'); 85 } 86 } 87 88 /// Extract the PDS endpoint from a DID document 89 /// 90 /// Looks for a service entry with: 91 /// - id ending in '#atproto_pds' 92 /// - type: 'AtprotoPersonalDataServer' 93 String _extractPDSEndpoint(Map<String, dynamic> didDoc) { 94 final services = didDoc['service'] as List<dynamic>?; 95 if (services == null || services.isEmpty) { 96 throw Exception('No services found in DID document'); 97 } 98 99 // Find the atproto_pds service 100 for (final service in services) { 101 final serviceMap = service as Map<String, dynamic>; 102 final id = serviceMap['id'] as String?; 103 final type = serviceMap['type'] as String?; 104 105 if (id != null && 106 id.endsWith('#atproto_pds') && 107 type == 'AtprotoPersonalDataServer') { 108 final endpoint = serviceMap['serviceEndpoint'] as String?; 109 if (endpoint == null) { 110 throw Exception('PDS service has no endpoint'); 111 } 112 113 // Remove trailing slash if present 114 return endpoint.endsWith('/') 115 ? endpoint.substring(0, endpoint.length - 1) 116 : endpoint; 117 } 118 } 119 120 throw Exception('No atproto_pds service found in DID document'); 121 } 122}