mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto): resolve handles via DoH TXT + .well-known fallback

+123
+68
Packages/ATProto/Sources/ATProto/Identity/HandleResolver.swift
··· 1 + import Foundation 2 + 3 + public struct HandleResolver: Sendable { 4 + let session: URLSession 5 + let dohEndpoint: URL 6 + 7 + public init( 8 + session: URLSession = .shared, 9 + dohEndpoint: URL = URL(string: "https://cloudflare-dns.com/dns-query")! 10 + ) { 11 + self.session = session 12 + self.dohEndpoint = dohEndpoint 13 + } 14 + 15 + public func resolve(handle: Handle) async throws -> DID { 16 + if let did = try? await resolveViaDoH(handle: handle) { 17 + return did 18 + } 19 + if let did = try? await resolveViaWellKnown(handle: handle) { 20 + return did 21 + } 22 + throw ATProtoError.invalidIdentity("could not resolve handle \(handle)") 23 + } 24 + 25 + func resolveViaDoH(handle: Handle) async throws -> DID { 26 + var comps = URLComponents(url: dohEndpoint, resolvingAgainstBaseURL: false)! 27 + comps.queryItems = [ 28 + URLQueryItem(name: "name", value: "_atproto.\(handle.rawValue)"), 29 + URLQueryItem(name: "type", value: "TXT"), 30 + ] 31 + var request = URLRequest(url: comps.url!) 32 + request.addValue("application/dns-json", forHTTPHeaderField: "Accept") 33 + let (data, _) = try await session.data(for: request) 34 + struct DoHResponse: Decodable { 35 + let Answer: [Answer]? 36 + struct Answer: Decodable { let data: String } 37 + } 38 + let decoded = try JSONDecoder().decode(DoHResponse.self, from: data) 39 + for answer in decoded.Answer ?? [] { 40 + // TXT data is JSON-escaped with surrounding quotes; strip them. 41 + let s = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) 42 + if let did = Self.parseDIDFromTXT(s) { 43 + return did 44 + } 45 + } 46 + throw ATProtoError.invalidIdentity("no atproto TXT record for \(handle)") 47 + } 48 + 49 + func resolveViaWellKnown(handle: Handle) async throws -> DID { 50 + let url = URL(string: "https://\(handle.rawValue)/.well-known/atproto-did")! 51 + let (data, response) = try await session.data(for: URLRequest(url: url)) 52 + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { 53 + throw ATProtoError.invalidIdentity("well-known returned non-2xx for \(handle)") 54 + } 55 + let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) 56 + guard let did = DID(text) else { 57 + throw ATProtoError.invalidIdentity("well-known body is not a DID: \(text)") 58 + } 59 + return did 60 + } 61 + 62 + /// Parses `did=did:plc:abc` from a TXT record body. 63 + static func parseDIDFromTXT(_ s: String) -> DID? { 64 + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) 65 + guard trimmed.hasPrefix("did=") else { return nil } 66 + return DID(String(trimmed.dropFirst("did=".count))) 67 + } 68 + }
+55
Packages/ATProto/Tests/ATProtoTests/HandleResolverTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("HandleResolver", .serialized) 6 + struct HandleResolverTests { 7 + @Test("resolves via DoH TXT record") 8 + func dohSuccess() async throws { 9 + let session = URLProtocolStub.install { request in 10 + let q = request.url?.query ?? "" 11 + if request.url?.host == "cloudflare-dns.com" && q.contains("name=_atproto.natemoo.re") { 12 + return .json(#""" 13 + {"Status":0,"Answer":[{"name":"_atproto.natemoo.re","type":16,"TTL":300,"data":"\"did=did:plc:abc\""}]} 14 + """#) 15 + } 16 + return URLProtocolStub.Response(statusCode: 500, headers: [:], body: Data()) 17 + } 18 + defer { URLProtocolStub.reset(session: session) } 19 + 20 + let resolver = HandleResolver(session: session) 21 + let did = try await resolver.resolve(handle: Handle("natemoo.re")!) 22 + #expect(did.rawValue == "did:plc:abc") 23 + } 24 + 25 + @Test("falls back to .well-known when DoH empty") 26 + func wellKnownFallback() async throws { 27 + let session = URLProtocolStub.install { request in 28 + if request.url?.host == "cloudflare-dns.com" { 29 + return .json(#"{"Status":0,"Answer":[]}"#) 30 + } 31 + if request.url?.host == "natemoo.re", request.url?.path == "/.well-known/atproto-did" { 32 + return .text("did:plc:xyz\n") 33 + } 34 + return URLProtocolStub.Response(statusCode: 500, headers: [:], body: Data()) 35 + } 36 + defer { URLProtocolStub.reset(session: session) } 37 + 38 + let resolver = HandleResolver(session: session) 39 + let did = try await resolver.resolve(handle: Handle("natemoo.re")!) 40 + #expect(did.rawValue == "did:plc:xyz") 41 + } 42 + 43 + @Test("throws invalidIdentity when both strategies fail") 44 + func bothFail() async { 45 + let session = URLProtocolStub.install { _ in 46 + URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 47 + } 48 + defer { URLProtocolStub.reset(session: session) } 49 + 50 + let resolver = HandleResolver(session: session) 51 + await #expect(throws: ATProtoError.self) { 52 + try await resolver.resolve(handle: Handle("ghost.example.com")!) 53 + } 54 + } 55 + }