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.

fix(atproto): did:web path form + port encoding, prefer #atproto_pds service id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+96 -11
+5 -4
Packages/ATProto/Sources/ATProto/Identity/DIDDocument.swift
··· 18 18 } 19 19 } 20 20 21 - /// First PDS endpoint service URL, if present. 21 + /// First PDS endpoint service URL. Prefers the canonical `#atproto_pds` 22 + /// service id; falls back to any service with type `AtprotoPersonalDataServer`. 22 23 public var pdsEndpoint: URL? { 23 - guard let svc = services.first(where: { $0.type == "AtprotoPersonalDataServer" }) else { 24 - return nil 25 - } 24 + let byID = services.first { $0.id == "#atproto_pds" } 25 + let byType = services.first { $0.type == "AtprotoPersonalDataServer" } 26 + guard let svc = byID ?? byType else { return nil } 26 27 return URL(string: svc.serviceEndpoint) 27 28 } 28 29 }
+40 -7
Packages/ATProto/Sources/ATProto/Identity/DIDResolver.swift
··· 27 27 let url = plcDirectory.appendingPathComponent(did.rawValue) 28 28 let (data, response) = try await session.data(for: URLRequest(url: url)) 29 29 guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { 30 - throw ATProtoError.invalidIdentity("plc.directory returned non-2xx for \(did)") 30 + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 31 + throw ATProtoError.invalidIdentity("plc.directory returned HTTP \(status) for \(did)") 31 32 } 32 33 return try JSONDecoder().decode(DIDDocument.self, from: data) 33 34 } 34 35 36 + /// Resolves a did:web DID per W3C spec: 37 + /// - `did:web:example.com` → https://example.com/.well-known/did.json 38 + /// - `did:web:example.com:user:alice` → https://example.com/user/alice/did.json 39 + /// - Colons in the host portion (ports) are percent-encoded as %3A in the DID. 35 40 func resolveWeb(did: DID) async throws -> DIDDocument { 36 - // did:web:<host>[:path...] → https://<host>/<path>/.well-known/did.json 37 - let parts = did.rawValue.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) 38 - guard parts.count == 3 else { throw ATProtoError.invalidIdentity("malformed did:web \(did)") } 39 - let rest = parts[2].replacingOccurrences(of: ":", with: "/") 40 - guard let url = URL(string: "https://\(rest)/.well-known/did.json") else { 41 + // Strip the `did:web:` prefix. 42 + let prefix = "did:web:" 43 + guard did.rawValue.hasPrefix(prefix) else { 44 + throw ATProtoError.invalidIdentity("malformed did:web \(did)") 45 + } 46 + let body = String(did.rawValue.dropFirst(prefix.count)) 47 + guard !body.isEmpty else { 48 + throw ATProtoError.invalidIdentity("malformed did:web \(did) (empty body)") 49 + } 50 + 51 + // Segments are colon-separated. First = host (possibly with %3A-encoded port), 52 + // remainder = path components. 53 + let segments = body.split(separator: ":", omittingEmptySubsequences: false).map(String.init) 54 + guard let hostSegment = segments.first, !hostSegment.isEmpty else { 55 + throw ATProtoError.invalidIdentity("malformed did:web \(did) (empty host)") 56 + } 57 + // Percent-decode the host segment so %3A → : 58 + guard let host = hostSegment.removingPercentEncoding else { 59 + throw ATProtoError.invalidIdentity("bad percent-encoding in host of \(did)") 60 + } 61 + let pathSegments = segments.dropFirst().map { segment -> String in 62 + segment.removingPercentEncoding ?? segment 63 + } 64 + 65 + let path: String 66 + if pathSegments.isEmpty { 67 + path = "/.well-known/did.json" 68 + } else { 69 + path = "/" + pathSegments.joined(separator: "/") + "/did.json" 70 + } 71 + 72 + guard let url = URL(string: "https://\(host)\(path)") else { 41 73 throw ATProtoError.invalidIdentity("bad did:web URL for \(did)") 42 74 } 43 75 let (data, response) = try await session.data(for: URLRequest(url: url)) 44 76 guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { 45 - throw ATProtoError.invalidIdentity("did:web returned non-2xx for \(did)") 77 + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 78 + throw ATProtoError.invalidIdentity("did:web returned HTTP \(status) for \(did)") 46 79 } 47 80 return try JSONDecoder().decode(DIDDocument.self, from: data) 48 81 }
+51
Packages/ATProto/Tests/ATProtoTests/DIDResolverTests.swift
··· 62 62 let doc = try await resolver.resolve(did: DID("did:plc:abc")!) 63 63 #expect(doc.pdsEndpoint == nil) 64 64 } 65 + 66 + @Test("resolves did:web with path form to /path/did.json (not .well-known)") 67 + func webWithPath() async throws { 68 + let session = URLProtocolStub.install { request in 69 + if request.url?.absoluteString == "https://example.com/user/alice/did.json" { 70 + return .json(#""" 71 + {"id":"did:web:example.com:user:alice", 72 + "service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}]} 73 + """#) 74 + } 75 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 76 + } 77 + defer { URLProtocolStub.reset(session: session) } 78 + 79 + let resolver = DIDResolver(session: session) 80 + let doc = try await resolver.resolve(did: DID("did:web:example.com:user:alice")!) 81 + #expect(doc.pdsEndpoint?.absoluteString == "https://pds.example.com") 82 + } 83 + 84 + @Test("resolves did:web with percent-encoded port") 85 + func webWithPort() async throws { 86 + let session = URLProtocolStub.install { request in 87 + if request.url?.absoluteString == "https://example.com:3000/.well-known/did.json" { 88 + return .json(#"{"id":"did:web:example.com%3A3000"}"#) 89 + } 90 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 91 + } 92 + defer { URLProtocolStub.reset(session: session) } 93 + 94 + let resolver = DIDResolver(session: session) 95 + let doc = try await resolver.resolve(did: DID("did:web:example.com%3A3000")!) 96 + #expect(doc.id == "did:web:example.com%3A3000") 97 + } 98 + 99 + @Test("pdsEndpoint prefers #atproto_pds id over type match") 100 + func pdsEndpointIDPreference() async throws { 101 + let session = URLProtocolStub.install { _ in 102 + .json(#""" 103 + {"id":"did:plc:abc", 104 + "service":[ 105 + {"id":"#other","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://wrong.example"}, 106 + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://correct.example"} 107 + ]} 108 + """#) 109 + } 110 + defer { URLProtocolStub.reset(session: session) } 111 + 112 + let resolver = DIDResolver(session: session) 113 + let doc = try await resolver.resolve(did: DID("did:plc:abc")!) 114 + #expect(doc.pdsEndpoint?.absoluteString == "https://correct.example") 115 + } 65 116 }