|
| 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