Main coves client
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}