Skip to content

Commit 74452cf

Browse files
committed
Merge remote-tracking branch 'refs/remotes/origin/develop'
2 parents 71dd877 + e5c3924 commit 74452cf

5 files changed

Lines changed: 158 additions & 5 deletions

File tree

Sources/ATProtoKit/APIReference/ComAtprotoAPI/ComAtprotoRepoDescribeRepoMethod.swift

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,14 @@ extension ATProtoKit {
3232
_ repositoryDID: String,
3333
pdsURL: String? = nil
3434
) async throws -> ComAtprotoLexicon.Repository.DescribeRepositoryOutput {
35-
// TODO: Change this back to "\(self.pdsURL)" once ATIdentityProtocol has been implemented.
36-
guard let requestURL = URL(string: "https://bsky.social/xrpc/com.atproto.repo.describeRepo") else {
35+
let host: String
36+
if let pdsURL, !pdsURL.isEmpty {
37+
host = pdsURL
38+
} else {
39+
host = await resolvePDSHost(for: repositoryDID)
40+
}
41+
42+
guard let requestURL = URL(string: "\(host)/xrpc/com.atproto.repo.describeRepo") else {
3743
throw ATRequestPrepareError.invalidRequestURL
3844
}
3945

@@ -66,4 +72,27 @@ extension ATProtoKit {
6672
throw error
6773
}
6874
}
75+
76+
/// Determines the base hostname for a repository-scoped request.
77+
///
78+
/// Some `com.atproto.repo.*` methods are implemented by the Personal Data Server (PDS) that
79+
/// hosts the target repository, which may differ from the instance's own PDS. This resolves
80+
/// the PDS service endpoint from the target DID via ``ATBuiltInIdentityResolver``, falling back
81+
/// to the instance's own ``pdsURL`` (the prior behaviour, served via entryway proxying) when
82+
/// resolution isn't possible.
83+
///
84+
/// Resolution is only attempted when `repository` is a DID; handles fall through to the
85+
/// instance's own host to preserve existing behaviour. Callers that already know the PDS URL
86+
/// should use it directly instead of calling this method.
87+
///
88+
/// - Parameter repository: The decentralized identifier (DID) or handle of the target repository.
89+
/// - Returns: The base hostname to use for the request.
90+
func resolvePDSHost(for repository: String) async -> String {
91+
if repository.hasPrefix("did:"),
92+
let endpoint = try? await ATBuiltInIdentityResolver().resolvePDSEndpoint(from: repository) {
93+
return endpoint
94+
}
95+
96+
return pdsURL
97+
}
6998
}

Sources/ATProtoKit/APIReference/ComAtprotoAPI/ComAtprotoRepoListRecordsMethod.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ extension ATProtoKit {
2828
/// - cursor: The mark used to indicate the starting point for the next set
2929
/// of results. Optional.
3030
/// - isArrayReverse: Indicates whether the list of records is listed in reverse. Optional.
31+
/// - pdsURL: The URL of the Personal Data Server (PDS). Optional. Defaults to `nil`.
3132
/// - Returns: An array of records, with an optional cursor to extend the array.
3233
///
3334
/// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to
@@ -37,10 +38,17 @@ extension ATProtoKit {
3738
collection: String,
3839
limit: Int? = 50,
3940
cursor: String? = nil,
40-
isArrayReverse: Bool? = nil
41+
isArrayReverse: Bool? = nil,
42+
pdsURL: String? = nil
4143
) async throws -> ComAtprotoLexicon.Repository.ListRecordsOutput {
42-
// TODO: Change this back to "\(self.pdsURL)" once ATIdentityProtocol has been implemented.
43-
guard let requestURL = URL(string: "https://bsky.social/xrpc/com.atproto.repo.listRecords") else {
44+
let host: String
45+
if let pdsURL, !pdsURL.isEmpty {
46+
host = pdsURL
47+
} else {
48+
host = await resolvePDSHost(for: repository)
49+
}
50+
51+
guard let requestURL = URL(string: "\(host)/xrpc/com.atproto.repo.listRecords") else {
4452
throw ATRequestPrepareError.invalidRequestURL
4553
}
4654

Sources/ATProtoKit/Errors/ATProtoError.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,22 @@ public enum ATDIDError: ATProtoError {
207207
case failedToValidateViaRegex
208208
}
209209

210+
/// An error type related to resolving an identity with ``ATBuiltInIdentityResolver``.
211+
public enum ATIdentityResolverError: ATProtoError {
212+
213+
/// The DID uses a method that the built-in resolver does not support.
214+
case unsupportedDIDMethod(String)
215+
216+
/// The DID could not be turned into a valid DID-document URL.
217+
case invalidDID(String)
218+
219+
/// The DID document could not be retrieved.
220+
case didDocumentUnavailable(did: String)
221+
222+
/// The DID document did not declare an AT Protocol PDS service.
223+
case pdsServiceNotFound(did: String)
224+
}
225+
210226
/// An error type related to issues with handles.
211227
public enum ATHandleError: ATProtoError {
212228

Sources/ATProtoKit/Utilities/APIHostname.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@ public enum APIHostname {
2424

2525
/// The hostname for the Ozone moderation API ("https://mod.bsky.app").
2626
public static let moderation: String = "https://mod.bsky.app"
27+
28+
/// The hostname for the PLC directory ("https://plc.directory").
29+
public static let plcDirectory: String = "https://plc.directory"
2730
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//
2+
// ATBuiltInIdentityResolver.swift
3+
// ATProtoKit
4+
//
5+
// Created by Christopher Jr Riley on 2026-06-30.
6+
//
7+
8+
import Foundation
9+
#if canImport(FoundationNetworking)
10+
import FoundationNetworking
11+
#endif
12+
13+
/// A lightweight, dependency-free ``ATIdentityProtocol`` conformer.
14+
///
15+
/// This resolver maps a decentralized identifier (DID) to its Personal Data Server (PDS)
16+
/// service endpoint by fetching the DID document directly — without requiring ATIdentityTools
17+
/// or an authenticated session.
18+
///
19+
/// It supports the `did:plc` and `did:web` methods. For richer identity handling (such as
20+
/// handle verification or caching policies), conform a type to ``ATIdentityProtocol`` using a
21+
/// dedicated package like ATIdentityTools instead.
22+
public struct ATBuiltInIdentityResolver: ATIdentityProtocol {
23+
24+
/// The URL session used for the lookups.
25+
public let urlSession: URLSession
26+
27+
/// The base hostname of the PLC directory.
28+
public let plcDirectoryURL: String
29+
30+
/// Creates a new built-in identity resolver.
31+
///
32+
/// - Parameters:
33+
/// - urlSession: The URL session used for the lookups. Defaults to `URLSession.shared`.
34+
/// - plcDirectoryURL: The base hostname of the PLC directory. Defaults to
35+
/// ``APIHostname/plcDirectory``.
36+
public init(
37+
urlSession: URLSession = .shared,
38+
plcDirectoryURL: String = APIHostname.plcDirectory
39+
) {
40+
self.urlSession = urlSession
41+
self.plcDirectoryURL = plcDirectoryURL
42+
}
43+
44+
public func resolvePDSEndpoint(from did: String) async throws -> String {
45+
let documentURL = try didDocumentURL(for: did)
46+
47+
let (data, response) = try await urlSession.data(from: documentURL)
48+
guard let httpResponse = response as? HTTPURLResponse,
49+
(200..<300).contains(httpResponse.statusCode) else {
50+
throw ATIdentityResolverError.didDocumentUnavailable(did: did)
51+
}
52+
53+
let document = try JSONDecoder().decode(DIDDocument.self, from: data)
54+
55+
guard let service = try? document.checkServiceForATProto() else {
56+
throw ATIdentityResolverError.pdsServiceNotFound(did: did)
57+
}
58+
59+
return service.serviceEndpoint.absoluteString
60+
}
61+
62+
/// Builds the DID-document URL for a supported DID method.
63+
///
64+
/// - Parameter did: The DID to build the document URL for.
65+
/// - Returns: The URL of the DID document.
66+
private func didDocumentURL(for did: String) throws -> URL {
67+
if did.hasPrefix("did:plc:") {
68+
// did:plc:xxxx → https://plc.directory/did:plc:xxxx
69+
guard let url = URL(string: "\(plcDirectoryURL)/\(did)") else {
70+
throw ATIdentityResolverError.invalidDID(did)
71+
}
72+
73+
return url
74+
} else if did.hasPrefix("did:web:") {
75+
// did:web:example.com → https://example.com/.well-known/did.json
76+
// did:web:example.com:a:b → https://example.com/a/b/did.json
77+
let identifier = String(did.dropFirst("did:web:".count))
78+
let segments = identifier.split(separator: ":").map(String.init)
79+
80+
guard let host = segments.first?.removingPercentEncoding else {
81+
throw ATIdentityResolverError.invalidDID(did)
82+
}
83+
84+
let path = segments.count > 1
85+
? "/" + segments.dropFirst().joined(separator: "/") + "/did.json"
86+
: "/.well-known/did.json"
87+
88+
guard let url = URL(string: "https://\(host)\(path)") else {
89+
throw ATIdentityResolverError.invalidDID(did)
90+
}
91+
92+
return url
93+
} else {
94+
throw ATIdentityResolverError.unsupportedDIDMethod(did)
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)