Main coves client
1import 'package:dio/dio.dart';
2
3import 'constants.dart';
4import 'did_document.dart';
5import 'did_helpers.dart';
6import 'identity_resolver_error.dart';
7
8/// Options for DID resolution.
9class ResolveDidOptions {
10 /// Whether to bypass cache
11 final bool noCache;
12
13 /// Cancellation token for the request
14 final CancelToken? cancelToken;
15
16 const ResolveDidOptions({this.noCache = false, this.cancelToken});
17}
18
19/// Interface for resolving DIDs to DID documents.
20abstract class DidResolver {
21 /// Resolves a DID to its DID document.
22 ///
23 /// Throws [DidResolverError] if resolution fails.
24 Future<DidDocument> resolve(String did, [ResolveDidOptions? options]);
25}
26
27/// DID resolver that supports both did:plc and did:web methods.
28class AtprotoDidResolver implements DidResolver {
29 final DidPlcMethod _plcMethod;
30 final DidWebMethod _webMethod;
31
32 AtprotoDidResolver({String? plcDirectoryUrl, Dio? dio})
33 : _plcMethod = DidPlcMethod(plcDirectoryUrl: plcDirectoryUrl, dio: dio),
34 _webMethod = DidWebMethod(dio: dio);
35
36 @override
37 Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
38 if (isDidPlc(did)) {
39 return _plcMethod.resolve(did, options);
40 } else if (isDidWeb(did)) {
41 return _webMethod.resolve(did, options);
42 } else {
43 throw DidResolverError(
44 'Unsupported DID method: ${extractDidMethod(did)}',
45 );
46 }
47 }
48}
49
50/// Resolver for did:plc identifiers using the PLC directory.
51class DidPlcMethod {
52 final Uri plcDirectoryUrl;
53 final Dio dio;
54
55 DidPlcMethod({String? plcDirectoryUrl, Dio? dio})
56 : plcDirectoryUrl = Uri.parse(plcDirectoryUrl ?? defaultPlcDirectoryUrl),
57 dio = dio ?? Dio();
58
59 Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
60 assertDidPlc(did);
61
62 final url = plcDirectoryUrl.resolve('/${Uri.encodeComponent(did)}');
63
64 try {
65 final response = await dio.getUri(
66 url,
67 options: Options(
68 headers: {
69 'Accept': 'application/did+ld+json,application/json',
70 if (options?.noCache ?? false) 'Cache-Control': 'no-cache',
71 },
72 followRedirects: false,
73 validateStatus: (status) => status == 200,
74 ),
75 cancelToken: options?.cancelToken,
76 );
77
78 if (response.data is! Map<String, dynamic>) {
79 throw DidResolverError(
80 'Invalid response format from PLC directory for $did',
81 );
82 }
83
84 return DidDocument.fromJson(response.data as Map<String, dynamic>);
85 } on DioException catch (e) {
86 if (e.type == DioExceptionType.cancel) {
87 throw DidResolverError('DID resolution was cancelled');
88 }
89
90 if (e.response?.statusCode == 404) {
91 throw DidResolverError('DID not found: $did');
92 }
93
94 throw DidResolverError(
95 'Failed to resolve DID from PLC directory: ${e.message}',
96 e,
97 );
98 } catch (e) {
99 if (e is DidResolverError) rethrow;
100
101 throw DidResolverError('Unexpected error resolving DID: $e', e);
102 }
103 }
104}
105
106/// Resolver for did:web identifiers using HTTPS.
107class DidWebMethod {
108 final Dio dio;
109
110 DidWebMethod({Dio? dio}) : dio = dio ?? Dio();
111
112 Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
113 assertDidWeb(did);
114
115 final baseUrl = didWebToUrl(did);
116
117 // Try /.well-known/did.json first, then /did.json
118 final urls = [
119 baseUrl.resolve('/.well-known/did.json'),
120 baseUrl.resolve('/did.json'),
121 ];
122
123 DioException? lastError;
124
125 for (final url in urls) {
126 try {
127 final response = await dio.getUri(
128 url,
129 options: Options(
130 headers: {
131 'Accept': 'application/did+ld+json,application/json',
132 if (options?.noCache ?? false) 'Cache-Control': 'no-cache',
133 },
134 followRedirects: false,
135 validateStatus: (status) => status == 200,
136 ),
137 cancelToken: options?.cancelToken,
138 );
139
140 if (response.data is! Map<String, dynamic>) {
141 throw DidResolverError(
142 'Invalid response format from did:web for $did',
143 );
144 }
145
146 final doc = DidDocument.fromJson(response.data as Map<String, dynamic>);
147
148 // Verify the DID in the document matches
149 if (doc.id != did) {
150 throw DidResolverError(
151 'DID mismatch: expected $did but got ${doc.id}',
152 );
153 }
154
155 return doc;
156 } on DioException catch (e) {
157 if (e.type == DioExceptionType.cancel) {
158 throw DidResolverError('DID resolution was cancelled');
159 }
160
161 // If not found, try the next URL
162 if (e.response?.statusCode == 404) {
163 lastError = e;
164 continue;
165 }
166
167 // Any other error, throw immediately
168 throw DidResolverError('Failed to resolve did:web: ${e.message}', e);
169 } catch (e) {
170 if (e is DidResolverError) rethrow;
171
172 throw DidResolverError('Unexpected error resolving did:web: $e', e);
173 }
174 }
175
176 // If we get here, all URLs failed
177 throw DidResolverError('DID document not found for $did', lastError);
178 }
179}
180
181/// Cached DID resolver that wraps another resolver with caching.
182class CachedDidResolver implements DidResolver {
183 final DidResolver _resolver;
184 final DidCache _cache;
185
186 CachedDidResolver(this._resolver, [DidCache? cache])
187 : _cache = cache ?? InMemoryDidCache();
188
189 @override
190 Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
191 // Check cache first unless noCache is set
192 if (!(options?.noCache ?? false)) {
193 final cached = await _cache.get(did);
194 if (cached != null) {
195 return cached;
196 }
197 }
198
199 // Resolve and cache
200 final doc = await _resolver.resolve(did, options);
201 await _cache.set(did, doc);
202
203 return doc;
204 }
205
206 /// Clears the cache
207 Future<void> clearCache() => _cache.clear();
208}
209
210/// Interface for caching DID documents.
211abstract class DidCache {
212 Future<DidDocument?> get(String did);
213 Future<void> set(String did, DidDocument document);
214 Future<void> clear();
215}
216
217/// Simple in-memory DID cache with expiration.
218class InMemoryDidCache implements DidCache {
219 final Map<String, _CacheEntry> _cache = {};
220 final Duration _ttl;
221
222 InMemoryDidCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 24);
223
224 @override
225 Future<DidDocument?> get(String did) async {
226 final entry = _cache[did];
227 if (entry == null) return null;
228
229 // Check if expired
230 if (DateTime.now().isAfter(entry.expiresAt)) {
231 _cache.remove(did);
232 return null;
233 }
234
235 return entry.document;
236 }
237
238 @override
239 Future<void> set(String did, DidDocument document) async {
240 _cache[did] = _CacheEntry(
241 document: document,
242 expiresAt: DateTime.now().add(_ttl),
243 );
244 }
245
246 @override
247 Future<void> clear() async {
248 _cache.clear();
249 }
250}
251
252class _CacheEntry {
253 final DidDocument document;
254 final DateTime expiresAt;
255
256 _CacheEntry({required this.document, required this.expiresAt});
257}