Main coves client
1import 'package:dio/dio.dart';
2
3import 'constants.dart';
4import 'did_document.dart';
5import 'did_helpers.dart';
6import 'did_resolver.dart';
7import 'handle_helpers.dart';
8import 'handle_resolver.dart';
9import 'identity_resolver_error.dart';
10
11/// Represents resolved identity information for an atProto user.
12///
13/// This combines DID, DID document, and validated handle information.
14class IdentityInfo {
15 /// The DID (Decentralized Identifier) for this identity
16 final String did;
17
18 /// The complete DID document
19 final DidDocument didDoc;
20
21 /// The validated handle, or 'handle.invalid' if handle validation failed
22 final String handle;
23
24 const IdentityInfo({
25 required this.did,
26 required this.didDoc,
27 required this.handle,
28 });
29
30 /// Whether the handle is valid (not 'handle.invalid')
31 bool get hasValidHandle => handle != handleInvalid;
32
33 /// Extracts the PDS URL from the DID document.
34 ///
35 /// Returns null if no PDS service is found.
36 String? get pdsUrl => didDoc.extractPdsUrl();
37}
38
39/// Options for identity resolution.
40class ResolveIdentityOptions {
41 /// Whether to bypass cache
42 final bool noCache;
43
44 /// Cancellation token for the request
45 final CancelToken? cancelToken;
46
47 const ResolveIdentityOptions({this.noCache = false, this.cancelToken});
48}
49
50/// Interface for resolving atProto identities (handles or DIDs) to complete identity info.
51abstract class IdentityResolver {
52 /// Resolves an identifier (handle or DID) to complete identity information.
53 ///
54 /// The identifier can be either:
55 /// - An atProto handle (e.g., "alice.bsky.social")
56 /// - A DID (e.g., "did:plc:...")
57 ///
58 /// Returns [IdentityInfo] with DID, DID document, and validated handle.
59 Future<IdentityInfo> resolve(
60 String identifier, [
61 ResolveIdentityOptions? options,
62 ]);
63}
64
65/// Implementation of the official atProto identity resolution strategy.
66///
67/// This resolver:
68/// 1. Determines if input is a handle or DID
69/// 2. Resolves handle → DID (if needed)
70/// 3. Fetches DID document
71/// 4. Validates bi-directional resolution (handle in DID doc matches original)
72/// 5. Extracts PDS URL from DID document
73///
74/// This is the **critical piece for decentralization** - it ensures users can
75/// host their data on any PDS, not just bsky.social.
76class AtprotoIdentityResolver implements IdentityResolver {
77 final DidResolver didResolver;
78 final HandleResolver handleResolver;
79
80 AtprotoIdentityResolver({
81 required this.didResolver,
82 required this.handleResolver,
83 });
84
85 /// Factory constructor with defaults for typical usage.
86 ///
87 /// [handleResolverUrl] should point to an atProto XRPC service that
88 /// implements com.atproto.identity.resolveHandle. Typically this is
89 /// https://bsky.social for public resolution, or your own PDS.
90 factory AtprotoIdentityResolver.withDefaults({
91 required String handleResolverUrl,
92 String? plcDirectoryUrl,
93 Dio? dio,
94 DidCache? didCache,
95 HandleCache? handleCache,
96 }) {
97 final dioInstance = dio ?? Dio();
98
99 final baseDidResolver = AtprotoDidResolver(
100 plcDirectoryUrl: plcDirectoryUrl,
101 dio: dioInstance,
102 );
103
104 final baseHandleResolver = XrpcHandleResolver(
105 handleResolverUrl,
106 dio: dioInstance,
107 );
108
109 return AtprotoIdentityResolver(
110 didResolver: CachedDidResolver(baseDidResolver, didCache),
111 handleResolver: CachedHandleResolver(baseHandleResolver, handleCache),
112 );
113 }
114
115 @override
116 Future<IdentityInfo> resolve(
117 String identifier, [
118 ResolveIdentityOptions? options,
119 ]) async {
120 return isDid(identifier)
121 ? resolveFromDid(identifier, options)
122 : resolveFromHandle(identifier, options);
123 }
124
125 /// Resolves identity starting from a DID.
126 ///
127 /// This:
128 /// 1. Fetches the DID document
129 /// 2. Extracts the handle from alsoKnownAs
130 /// 3. Validates that the handle resolves back to the same DID
131 Future<IdentityInfo> resolveFromDid(
132 String did, [
133 ResolveIdentityOptions? options,
134 ]) async {
135 final document = await getDocumentFromDid(did, options);
136
137 // We will only return the document's handle alias if it resolves to the
138 // same DID as the input (bi-directional validation)
139 final handle = document.extractNormalizedHandle();
140 String? resolvedDid;
141
142 if (handle != null) {
143 try {
144 resolvedDid = await handleResolver.resolve(
145 handle,
146 ResolveHandleOptions(
147 noCache: options?.noCache ?? false,
148 cancelToken: options?.cancelToken,
149 ),
150 );
151 } catch (e) {
152 // Ignore errors (handle might be temporarily unavailable)
153 resolvedDid = null;
154 }
155 }
156
157 return IdentityInfo(
158 did: document.id,
159 didDoc: document,
160 handle: handle != null && resolvedDid == did ? handle : handleInvalid,
161 );
162 }
163
164 /// Resolves identity starting from a handle.
165 ///
166 /// This:
167 /// 1. Resolves handle → DID
168 /// 2. Fetches DID document
169 /// 3. Validates that the DID document contains the original handle
170 Future<IdentityInfo> resolveFromHandle(
171 String handle, [
172 ResolveIdentityOptions? options,
173 ]) async {
174 final document = await getDocumentFromHandle(handle, options);
175
176 // Bi-directional resolution is enforced in getDocumentFromHandle()
177 return IdentityInfo(
178 did: document.id,
179 didDoc: document,
180 handle: document.extractNormalizedHandle() ?? handleInvalid,
181 );
182 }
183
184 /// Fetches a DID document from a DID.
185 Future<DidDocument> getDocumentFromDid(
186 String did, [
187 ResolveIdentityOptions? options,
188 ]) async {
189 return didResolver.resolve(
190 did,
191 ResolveDidOptions(
192 noCache: options?.noCache ?? false,
193 cancelToken: options?.cancelToken,
194 ),
195 );
196 }
197
198 /// Fetches a DID document from a handle with bi-directional validation.
199 ///
200 /// This method:
201 /// 1. Normalizes and validates the handle
202 /// 2. Resolves handle → DID
203 /// 3. Fetches DID document
204 /// 4. Verifies the DID document contains the original handle
205 Future<DidDocument> getDocumentFromHandle(
206 String input, [
207 ResolveIdentityOptions? options,
208 ]) async {
209 final handle = asNormalizedHandle(input);
210 if (handle == null) {
211 throw InvalidHandleError(input, 'Invalid handle format');
212 }
213
214 final did = await handleResolver.resolve(
215 handle,
216 ResolveHandleOptions(
217 noCache: options?.noCache ?? false,
218 cancelToken: options?.cancelToken,
219 ),
220 );
221
222 if (did == null) {
223 throw IdentityResolverError('Handle "$handle" does not resolve to a DID');
224 }
225
226 // Fetch the DID document
227 final document = await didResolver.resolve(
228 did,
229 ResolveDidOptions(
230 noCache: options?.noCache ?? false,
231 cancelToken: options?.cancelToken,
232 ),
233 );
234
235 // Enforce bi-directional resolution
236 final docHandle = document.extractNormalizedHandle();
237 if (handle != docHandle) {
238 throw IdentityResolverError(
239 'DID document for "$did" does not include the handle "$handle" '
240 '(found: ${docHandle ?? "none"})',
241 );
242 }
243
244 return document;
245 }
246
247 /// Convenience method to resolve directly to PDS URL.
248 ///
249 /// This is the most common use case: given a handle or DID, find the PDS URL.
250 Future<String> resolveToPds(
251 String identifier, [
252 ResolveIdentityOptions? options,
253 ]) async {
254 final info = await resolve(identifier, options);
255 final pdsUrl = info.pdsUrl;
256
257 if (pdsUrl == null) {
258 throw IdentityResolverError(
259 'No PDS endpoint found in DID document for $identifier',
260 );
261 }
262
263 return pdsUrl;
264 }
265}
266
267/// Options for creating an identity resolver.
268class IdentityResolverOptions {
269 /// Custom identity resolver (if not provided, AtprotoIdentityResolver is used)
270 final IdentityResolver? identityResolver;
271
272 /// Custom DID resolver
273 final DidResolver? didResolver;
274
275 /// Custom handle resolver (or URL string for XRPC resolver)
276 final dynamic handleResolver; // HandleResolver, String, or Uri
277
278 /// Custom DID cache
279 final DidCache? didCache;
280
281 /// Custom handle cache
282 final HandleCache? handleCache;
283
284 /// Custom Dio instance for HTTP requests
285 final Dio? dio;
286
287 /// PLC directory URL (defaults to https://plc.directory/)
288 final String? plcDirectoryUrl;
289
290 const IdentityResolverOptions({
291 this.identityResolver,
292 this.didResolver,
293 this.handleResolver,
294 this.didCache,
295 this.handleCache,
296 this.dio,
297 this.plcDirectoryUrl,
298 });
299}
300
301/// Creates an identity resolver with the given options.
302///
303/// This is the main entry point for creating an identity resolver.
304/// It handles setting up default implementations with proper caching.
305IdentityResolver createIdentityResolver(IdentityResolverOptions options) {
306 // If a custom identity resolver is provided, use it
307 if (options.identityResolver != null) {
308 return options.identityResolver!;
309 }
310
311 final dioInstance = options.dio ?? Dio();
312
313 // Create DID resolver
314 final didResolver = _createDidResolver(options, dioInstance);
315
316 // Create handle resolver
317 final handleResolver = _createHandleResolver(options, dioInstance);
318
319 return AtprotoIdentityResolver(
320 didResolver: didResolver,
321 handleResolver: handleResolver,
322 );
323}
324
325DidResolver _createDidResolver(IdentityResolverOptions options, Dio dio) {
326 final didResolver =
327 options.didResolver ??
328 AtprotoDidResolver(plcDirectoryUrl: options.plcDirectoryUrl, dio: dio);
329
330 // Wrap with cache if not already cached
331 if (didResolver is CachedDidResolver && options.didCache == null) {
332 return didResolver;
333 }
334
335 return CachedDidResolver(didResolver, options.didCache);
336}
337
338HandleResolver _createHandleResolver(IdentityResolverOptions options, Dio dio) {
339 final handleResolverInput = options.handleResolver;
340
341 if (handleResolverInput == null) {
342 throw ArgumentError(
343 'handleResolver is required. Provide either a HandleResolver instance, '
344 'a URL string, or a Uri pointing to an XRPC service.',
345 );
346 }
347
348 HandleResolver baseResolver;
349
350 if (handleResolverInput is HandleResolver) {
351 baseResolver = handleResolverInput;
352 } else if (handleResolverInput is String || handleResolverInput is Uri) {
353 baseResolver = XrpcHandleResolver(handleResolverInput.toString(), dio: dio);
354 } else {
355 throw ArgumentError(
356 'handleResolver must be a HandleResolver, String, or Uri',
357 );
358 }
359
360 // Wrap with cache if not already cached
361 if (baseResolver is CachedHandleResolver && options.handleCache == null) {
362 return baseResolver;
363 }
364
365 return CachedHandleResolver(baseResolver, options.handleCache);
366}