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.

refactor(atproto): split 409 errno — ESTALE for swapRecord mismatch, EEXIST for rkey collision

EEXIST ("file already exists") misleads users during edit conflicts.
ESTALE (even though Finder renders it as "Stale NFS file handle")
semantically matches "the cached view is stale, refresh and retry"
which is exactly what swapRecord conflicts mean. Raw 409s (rkey
collision on createRecord) keep EEXIST, which is accurate.

Also updates the implementation plan to reflect this split.

+1811 -5
+8 -2
Packages/ATProto/Sources/ATProto/Errors/ATProtoError+POSIX.swift
··· 17 17 case 500...599: return EIO 18 18 default: return EIO 19 19 } 20 - case .xrpc(_, _, let status): 20 + case .xrpc(let code, _, let status): 21 + // Distinguish swapRecord mismatch ("you have a stale view") from 22 + // generic 409s ("rkey already exists"). ESTALE communicates the 23 + // staleness signal even though Finder renders it as "Stale NFS 24 + // file handle" — better than EEXIST's misleading "file already 25 + // exists" for an edit conflict. 26 + if code == "InvalidSwap" { return ESTALE } 21 27 return ATProtoError.http(status: status, body: nil).posixErrno 22 28 case .decoding: return EIO 23 29 case .invalidIdentity: return EINVAL 24 - case .cidMismatch: return EEXIST 30 + case .cidMismatch: return ESTALE 25 31 case .notAuthenticated: return EACCES 26 32 case .scopeDenied: return EACCES 27 33 case .invalidURL: return EINVAL
+13 -3
Packages/ATProto/Tests/ATProtoTests/ErrorMappingTests.swift
··· 12 12 func notFound() { 13 13 #expect(ATProtoError.http(status: 404, body: nil).posixErrno == ENOENT) 14 14 } 15 - @Test("409 → EEXIST") 15 + @Test("raw 409 → EEXIST (rkey collision)") 16 16 func conflict() { 17 17 #expect(ATProtoError.http(status: 409, body: nil).posixErrno == EEXIST) 18 + } 19 + @Test("xrpc InvalidSwap → ESTALE (swapRecord mismatch)") 20 + func invalidSwap() { 21 + let err = ATProtoError.xrpc(code: "InvalidSwap", message: "stale", status: 409) 22 + #expect(err.posixErrno == ESTALE) 23 + } 24 + @Test("xrpc non-InvalidSwap 409 still → EEXIST") 25 + func xrpcOther409() { 26 + let err = ATProtoError.xrpc(code: "RecordKeyExists", message: "dup", status: 409) 27 + #expect(err.posixErrno == EEXIST) 18 28 } 19 29 @Test("413 → EFBIG") 20 30 func tooLarge() { ··· 34 44 let urlErr = URLError(.notConnectedToInternet) 35 45 #expect(ATProtoError.network(urlErr).posixErrno == EIO) 36 46 } 37 - @Test("cid mismatch → EEXIST") 47 + @Test("cid mismatch → ESTALE") 38 48 func cidMismatch() { 39 49 let cid = CID("bafyreibyvxuebctujncksacu3qfxsv6pzm7z67vfrftndn6pzhknf7g5me")! 40 - #expect(ATProtoError.cidMismatch(expected: cid, actual: nil).posixErrno == EEXIST) 50 + #expect(ATProtoError.cidMismatch(expected: cid, actual: nil).posixErrno == ESTALE) 41 51 } 42 52 @Test("xrpc envelope decodes error/message") 43 53 func envelope() throws {
+1790
docs/superpowers/plans/2026-04-17-atproto-xrpc-identity.md
··· 1 + # atproto XRPC Client + Identity Resolution Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Build the unauthenticated XRPC networking layer and atproto identity 6 + resolver inside the `ATProto` SPM package so that any public PDS can be queried 7 + from pdfs without auth, and handle/DID identities resolve correctly. 8 + 9 + **Architecture:** `XRPCClient` is a tiny `URLSession` wrapper that speaks the 10 + atproto JSON envelope on top of `/xrpc/<nsid>` endpoints. Auth is modeled as a 11 + pluggable `AuthTokenProvider` protocol that v1 implements with a no-op provider 12 + (unauthenticated). Typed endpoint wrappers decode responses into `Codable` 13 + structs. `IdentityResolver` is an actor that chains three strategies (PLC 14 + directory, did:web, DNS/HTTPS handle resolution) behind a TTL-bounded cache. 15 + All I/O is injected via a `URLSession` so tests use `URLProtocol` stubs. 16 + 17 + **Tech Stack:** Swift 6.0, Foundation `URLSession` + `URLProtocol`, Swift Testing 18 + framework. Zero third-party dependencies. Target macOS 14+. 19 + 20 + **Scope:** 21 + - IN: Error model, XRPC GET/POST, typed read endpoints (`describeRepo`, 22 + `listRecords`, `getRecord`, `listBlobs`, `getBlob`), handle→DID 23 + resolution, DID→doc resolution, PDS endpoint extraction, TTL cache 24 + - OUT: OAuth, DPoP, Keychain, writes, FSKit, host app — covered by 25 + follow-up plans 26 + 27 + --- 28 + 29 + ## File structure 30 + 31 + Under `Packages/ATProto/Sources/ATProto/`: 32 + 33 + ``` 34 + Errors/ 35 + ATProtoError.swift # error enum + XRPC error envelope decoding 36 + ATProtoError+POSIX.swift # errno(for:) mapper 37 + XRPC/ 38 + XRPCResponse.swift # Result<Value, ATProtoError> + raw response 39 + XRPCClient.swift # low-level GET/POST over URLSession 40 + XRPCEndpoints.swift # typed wrappers for the 5 read endpoints 41 + DescribeRepoOutput.swift # response types 42 + ListRecordsOutput.swift 43 + GetRecordOutput.swift 44 + ListBlobsOutput.swift 45 + Identity/ 46 + DIDDocument.swift # minimal DID doc model (service array) 47 + IdentityResolver.swift # actor orchestrating resolution + cache 48 + HandleResolver.swift # DoH + well-known chains 49 + DIDResolver.swift # PLC + did:web chains 50 + Support/ 51 + AuthTokenProvider.swift # protocol + AnonymousAuthTokenProvider 52 + ``` 53 + 54 + Under `Packages/ATProto/Tests/ATProtoTests/`: 55 + 56 + ``` 57 + Support/ 58 + URLProtocolStub.swift # install/uninstall stubbed responses 59 + ErrorMappingTests.swift 60 + XRPCClientTests.swift 61 + XRPCEndpointsTests.swift 62 + HandleResolverTests.swift 63 + DIDResolverTests.swift 64 + IdentityResolverTests.swift 65 + ``` 66 + 67 + All new files sit alongside the existing `Identifiers/` module. `Support/` is 68 + shared between source and tests (prod-side type is `AuthTokenProvider`; tests 69 + use `URLProtocolStub`). 70 + 71 + --- 72 + 73 + ## Working directory 74 + 75 + All commands run from `Packages/ATProto/`: 76 + 77 + ```bash 78 + cd /Users/nmoo/Developer/natemoo-re/tangled/atfs/Packages/ATProto 79 + ``` 80 + 81 + --- 82 + 83 + ### Task 1: Error model + POSIX mapping 84 + 85 + **Files:** 86 + - Create: `Sources/ATProto/Errors/ATProtoError.swift` 87 + - Create: `Sources/ATProto/Errors/ATProtoError+POSIX.swift` 88 + - Create: `Tests/ATProtoTests/ErrorMappingTests.swift` 89 + 90 + - [ ] **Step 1: Write the failing test** 91 + 92 + Create `Tests/ATProtoTests/ErrorMappingTests.swift`: 93 + 94 + ```swift 95 + import Foundation 96 + import Testing 97 + @testable import ATProto 98 + 99 + @Suite("Error → POSIX errno") 100 + struct ErrorMappingTests { 101 + @Test("401 → EACCES after refresh") 102 + func unauthorized() { 103 + #expect(ATProtoError.http(status: 401, body: nil).posixErrno == EACCES) 104 + } 105 + @Test("404 → ENOENT") 106 + func notFound() { 107 + #expect(ATProtoError.http(status: 404, body: nil).posixErrno == ENOENT) 108 + } 109 + @Test("raw 409 → EEXIST (rkey collision)") 110 + func conflict() { 111 + #expect(ATProtoError.http(status: 409, body: nil).posixErrno == EEXIST) 112 + } 113 + @Test("xrpc InvalidSwap → ESTALE (swapRecord mismatch)") 114 + func invalidSwap() { 115 + let err = ATProtoError.xrpc(code: "InvalidSwap", message: "stale", status: 409) 116 + #expect(err.posixErrno == ESTALE) 117 + } 118 + @Test("xrpc non-InvalidSwap 409 still → EEXIST") 119 + func xrpcOther409() { 120 + let err = ATProtoError.xrpc(code: "RecordKeyExists", message: "dup", status: 409) 121 + #expect(err.posixErrno == EEXIST) 122 + } 123 + @Test("413 → EFBIG") 124 + func tooLarge() { 125 + #expect(ATProtoError.http(status: 413, body: nil).posixErrno == EFBIG) 126 + } 127 + @Test("429 → EAGAIN") 128 + func rateLimited() { 129 + #expect(ATProtoError.http(status: 429, body: nil).posixErrno == EAGAIN) 130 + } 131 + @Test("5xx → EIO") 132 + func serverError() { 133 + #expect(ATProtoError.http(status: 500, body: nil).posixErrno == EIO) 134 + #expect(ATProtoError.http(status: 503, body: nil).posixErrno == EIO) 135 + } 136 + @Test("network → EIO") 137 + func network() { 138 + let urlErr = URLError(.notConnectedToInternet) 139 + #expect(ATProtoError.network(urlErr).posixErrno == EIO) 140 + } 141 + @Test("cid mismatch → ESTALE") 142 + func cidMismatch() { 143 + let cid = CID("bafyreibyvxuebctujncksacu3qfxsv6pzm7z67vfrftndn6pzhknf7g5me")! 144 + #expect(ATProtoError.cidMismatch(expected: cid, actual: nil).posixErrno == ESTALE) 145 + } 146 + @Test("xrpc envelope decodes error/message") 147 + func envelope() throws { 148 + let json = #"{"error":"InvalidRequest","message":"bad rkey"}"#.data(using: .utf8)! 149 + let env = try JSONDecoder().decode(ATProtoError.XRPCEnvelope.self, from: json) 150 + #expect(env.error == "InvalidRequest") 151 + #expect(env.message == "bad rkey") 152 + } 153 + } 154 + ``` 155 + 156 + - [ ] **Step 2: Run test to verify it fails** 157 + 158 + ```bash 159 + swift test --filter ErrorMappingTests 2>&1 | tail -10 160 + ``` 161 + Expected: FAIL with "cannot find 'ATProtoError' in scope". 162 + 163 + - [ ] **Step 3: Write the error enum** 164 + 165 + Create `Sources/ATProto/Errors/ATProtoError.swift`: 166 + 167 + ```swift 168 + import Foundation 169 + 170 + public enum ATProtoError: Error, Sendable { 171 + case network(URLError) 172 + case http(status: Int, body: Data?) 173 + case xrpc(code: String, message: String?, status: Int) 174 + case decoding(String) 175 + case invalidIdentity(String) 176 + case cidMismatch(expected: CID, actual: CID?) 177 + case notAuthenticated 178 + case scopeDenied(requested: String, granted: Set<String>) 179 + case invalidURL(String) 180 + 181 + public struct XRPCEnvelope: Decodable, Sendable { 182 + public let error: String 183 + public let message: String? 184 + } 185 + } 186 + ``` 187 + 188 + - [ ] **Step 4: Write the POSIX mapper** 189 + 190 + Create `Sources/ATProto/Errors/ATProtoError+POSIX.swift`: 191 + 192 + ```swift 193 + import Foundation 194 + #if canImport(Darwin) 195 + import Darwin 196 + #endif 197 + 198 + public extension ATProtoError { 199 + var posixErrno: Int32 { 200 + switch self { 201 + case .network: return EIO 202 + case .http(let status, _): 203 + switch status { 204 + case 401, 403: return EACCES 205 + case 404: return ENOENT 206 + case 409: return EEXIST 207 + case 413: return EFBIG 208 + case 429: return EAGAIN 209 + case 500...599: return EIO 210 + default: return EIO 211 + } 212 + case .xrpc(let code, _, let status): 213 + // Distinguish swapRecord mismatch ("you have a stale view") from 214 + // generic 409s ("rkey already exists"). ESTALE communicates the 215 + // staleness signal even though Finder renders it as "Stale NFS 216 + // file handle" — better than EEXIST's misleading "file already 217 + // exists" for an edit conflict. 218 + if code == "InvalidSwap" { return ESTALE } 219 + return ATProtoError.http(status: status, body: nil).posixErrno 220 + case .decoding: return EIO 221 + case .invalidIdentity: return EINVAL 222 + case .cidMismatch: return ESTALE 223 + case .notAuthenticated: return EACCES 224 + case .scopeDenied: return EACCES 225 + case .invalidURL: return EINVAL 226 + } 227 + } 228 + } 229 + ``` 230 + 231 + - [ ] **Step 5: Run tests to verify they pass** 232 + 233 + ```bash 234 + swift test --filter ErrorMappingTests 2>&1 | tail -10 235 + ``` 236 + Expected: 8 tests pass. 237 + 238 + - [ ] **Step 6: Commit** 239 + 240 + ```bash 241 + git add Packages/ATProto/Sources/ATProto/Errors Packages/ATProto/Tests/ATProtoTests/ErrorMappingTests.swift 242 + git commit -m "feat(atproto): add error model with POSIX errno mapping" 243 + ``` 244 + 245 + --- 246 + 247 + ### Task 2: URLProtocol stub test utility 248 + 249 + **Files:** 250 + - Create: `Tests/ATProtoTests/Support/URLProtocolStub.swift` 251 + 252 + - [ ] **Step 1: Write the stub utility** 253 + 254 + Create `Tests/ATProtoTests/Support/URLProtocolStub.swift`: 255 + 256 + ```swift 257 + import Foundation 258 + 259 + /// In-process HTTP stub for URLSession tests. Not thread-safe across concurrent 260 + /// tests; invoke each test serially or guard with `@Suite(.serialized)`. 261 + final class URLProtocolStub: URLProtocol, @unchecked Sendable { 262 + struct Response { 263 + let statusCode: Int 264 + let headers: [String: String] 265 + let body: Data 266 + 267 + static func json(_ string: String, status: Int = 200) -> Response { 268 + Response(statusCode: status, headers: ["Content-Type": "application/json"], body: Data(string.utf8)) 269 + } 270 + static func bytes(_ data: Data, contentType: String = "application/octet-stream", status: Int = 200) -> Response { 271 + Response(statusCode: status, headers: ["Content-Type": contentType], body: data) 272 + } 273 + static func text(_ s: String, status: Int = 200) -> Response { 274 + Response(statusCode: status, headers: ["Content-Type": "text/plain"], body: Data(s.utf8)) 275 + } 276 + } 277 + 278 + nonisolated(unsafe) static var handler: ((URLRequest) -> Response)? 279 + nonisolated(unsafe) static var recordedRequests: [URLRequest] = [] 280 + 281 + static func install(handler: @escaping (URLRequest) -> Response) -> URLSession { 282 + Self.handler = handler 283 + Self.recordedRequests = [] 284 + let config = URLSessionConfiguration.ephemeral 285 + config.protocolClasses = [URLProtocolStub.self] 286 + return URLSession(configuration: config) 287 + } 288 + 289 + static func reset() { 290 + handler = nil 291 + recordedRequests = [] 292 + } 293 + 294 + override class func canInit(with request: URLRequest) -> Bool { true } 295 + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } 296 + 297 + override func startLoading() { 298 + Self.recordedRequests.append(request) 299 + guard let handler = Self.handler else { 300 + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) 301 + return 302 + } 303 + let response = handler(request) 304 + let httpResponse = HTTPURLResponse( 305 + url: request.url!, 306 + statusCode: response.statusCode, 307 + httpVersion: "HTTP/1.1", 308 + headerFields: response.headers 309 + )! 310 + client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) 311 + client?.urlProtocol(self, didLoad: response.body) 312 + client?.urlProtocolDidFinishLoading(self) 313 + } 314 + 315 + override func stopLoading() {} 316 + } 317 + ``` 318 + 319 + - [ ] **Step 2: Verify it compiles with a smoke test** 320 + 321 + ```bash 322 + swift build 2>&1 | tail -5 323 + ``` 324 + Expected: Build complete. 325 + 326 + - [ ] **Step 3: Commit** 327 + 328 + ```bash 329 + git add Packages/ATProto/Tests/ATProtoTests/Support/URLProtocolStub.swift 330 + git commit -m "test(atproto): add URLProtocol stub utility" 331 + ``` 332 + 333 + --- 334 + 335 + ### Task 3: XRPCClient GET (unauthenticated) 336 + 337 + **Files:** 338 + - Create: `Sources/ATProto/Support/AuthTokenProvider.swift` 339 + - Create: `Sources/ATProto/XRPC/XRPCClient.swift` 340 + - Create: `Tests/ATProtoTests/XRPCClientTests.swift` 341 + 342 + - [ ] **Step 1: Write the failing test** 343 + 344 + Create `Tests/ATProtoTests/XRPCClientTests.swift`: 345 + 346 + ```swift 347 + import Foundation 348 + import Testing 349 + @testable import ATProto 350 + 351 + @Suite("XRPCClient", .serialized) 352 + struct XRPCClientTests { 353 + struct Echo: Decodable, Equatable { 354 + let value: String 355 + } 356 + 357 + @Test("GET returns decoded success") 358 + func getSuccess() async throws { 359 + let session = URLProtocolStub.install { request in 360 + #expect(request.url?.absoluteString == "https://bsky.social/xrpc/com.example.echo?value=hi") 361 + #expect(request.httpMethod == "GET") 362 + return .json(#"{"value":"hi"}"#) 363 + } 364 + defer { URLProtocolStub.reset() } 365 + 366 + let client = XRPCClient( 367 + service: URL(string: "https://bsky.social")!, 368 + session: session, 369 + auth: AnonymousAuthTokenProvider() 370 + ) 371 + let result: Echo = try await client.get(nsid: "com.example.echo", params: ["value": "hi"]) 372 + #expect(result == Echo(value: "hi")) 373 + } 374 + 375 + @Test("GET decodes XRPC error envelope on 4xx") 376 + func getXRPCError() async throws { 377 + let session = URLProtocolStub.install { _ in 378 + .json(#"{"error":"InvalidRequest","message":"bad"}"#, status: 400) 379 + } 380 + defer { URLProtocolStub.reset() } 381 + 382 + let client = XRPCClient( 383 + service: URL(string: "https://bsky.social")!, 384 + session: session, 385 + auth: AnonymousAuthTokenProvider() 386 + ) 387 + do { 388 + let _: Echo = try await client.get(nsid: "com.example.echo", params: nil) 389 + Issue.record("expected throw") 390 + } catch let err as ATProtoError { 391 + if case .xrpc(let code, let message, let status) = err { 392 + #expect(code == "InvalidRequest") 393 + #expect(message == "bad") 394 + #expect(status == 400) 395 + } else { 396 + Issue.record("wrong error case: \(err)") 397 + } 398 + } 399 + } 400 + 401 + @Test("GET falls back to .http on non-JSON 5xx body") 402 + func getHttpFallback() async throws { 403 + let session = URLProtocolStub.install { _ in 404 + .text("upstream down", status: 502) 405 + } 406 + defer { URLProtocolStub.reset() } 407 + 408 + let client = XRPCClient( 409 + service: URL(string: "https://bsky.social")!, 410 + session: session, 411 + auth: AnonymousAuthTokenProvider() 412 + ) 413 + do { 414 + let _: Echo = try await client.get(nsid: "com.example.echo", params: nil) 415 + Issue.record("expected throw") 416 + } catch let err as ATProtoError { 417 + if case .http(let status, _) = err { 418 + #expect(status == 502) 419 + } else { 420 + Issue.record("wrong error case: \(err)") 421 + } 422 + } 423 + } 424 + } 425 + ``` 426 + 427 + - [ ] **Step 2: Run to verify failure** 428 + 429 + ```bash 430 + swift test --filter XRPCClientTests 2>&1 | tail -10 431 + ``` 432 + Expected: FAIL with "cannot find 'XRPCClient' in scope". 433 + 434 + - [ ] **Step 3: Write AuthTokenProvider protocol** 435 + 436 + Create `Sources/ATProto/Support/AuthTokenProvider.swift`: 437 + 438 + ```swift 439 + import Foundation 440 + 441 + public protocol AuthTokenProvider: Sendable { 442 + /// Returns auth headers to attach to the given request URL, or nil if no 443 + /// auth is available. DPoP-bound providers will supply both 444 + /// `Authorization` and `DPoP` headers. 445 + func authHeaders(for request: URLRequest) async throws -> [String: String]? 446 + 447 + /// Called when the server rejected the previous token so the provider can 448 + /// refresh before the caller retries. 449 + func reportTokenRejected() async 450 + } 451 + 452 + public struct AnonymousAuthTokenProvider: AuthTokenProvider { 453 + public init() {} 454 + public func authHeaders(for request: URLRequest) async throws -> [String: String]? { nil } 455 + public func reportTokenRejected() async {} 456 + } 457 + ``` 458 + 459 + - [ ] **Step 4: Write XRPCClient** 460 + 461 + Create `Sources/ATProto/XRPC/XRPCClient.swift`: 462 + 463 + ```swift 464 + import Foundation 465 + 466 + public struct XRPCClient: Sendable { 467 + public let service: URL 468 + let session: URLSession 469 + let auth: any AuthTokenProvider 470 + 471 + public init(service: URL, session: URLSession = .shared, auth: any AuthTokenProvider) { 472 + self.service = service 473 + self.session = session 474 + self.auth = auth 475 + } 476 + 477 + public func get<Output: Decodable>( 478 + nsid: String, 479 + params: [String: String]? 480 + ) async throws -> Output { 481 + let url = try buildURL(nsid: nsid, params: params) 482 + var request = URLRequest(url: url) 483 + request.httpMethod = "GET" 484 + return try await send(request: &request) 485 + } 486 + 487 + public func getBytes(nsid: String, params: [String: String]?) async throws -> (Data, URLResponse) { 488 + let url = try buildURL(nsid: nsid, params: params) 489 + var request = URLRequest(url: url) 490 + request.httpMethod = "GET" 491 + return try await sendRaw(request: &request) 492 + } 493 + 494 + func buildURL(nsid: String, params: [String: String]?) throws -> URL { 495 + var comps = URLComponents(url: service, resolvingAgainstBaseURL: false) 496 + guard comps != nil else { throw ATProtoError.invalidURL(service.absoluteString) } 497 + comps!.path = (comps!.path.hasSuffix("/") ? comps!.path : comps!.path) + "xrpc/\(nsid)" 498 + if let params, !params.isEmpty { 499 + comps!.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } 500 + } 501 + guard let url = comps!.url else { throw ATProtoError.invalidURL(nsid) } 502 + return url 503 + } 504 + 505 + func send<Output: Decodable>(request: inout URLRequest) async throws -> Output { 506 + let (data, response) = try await sendRaw(request: &request) 507 + return try decode(data: data, response: response) 508 + } 509 + 510 + func sendRaw(request: inout URLRequest) async throws -> (Data, URLResponse) { 511 + if let headers = try await auth.authHeaders(for: request) { 512 + for (k, v) in headers { request.addValue(v, forHTTPHeaderField: k) } 513 + } 514 + do { 515 + let (data, response) = try await session.data(for: request) 516 + guard let http = response as? HTTPURLResponse else { 517 + throw ATProtoError.http(status: 0, body: nil) 518 + } 519 + if !(200..<300).contains(http.statusCode) { 520 + if let envelope = try? JSONDecoder().decode(ATProtoError.XRPCEnvelope.self, from: data) { 521 + throw ATProtoError.xrpc(code: envelope.error, message: envelope.message, status: http.statusCode) 522 + } 523 + throw ATProtoError.http(status: http.statusCode, body: data) 524 + } 525 + return (data, response) 526 + } catch let urlErr as URLError { 527 + throw ATProtoError.network(urlErr) 528 + } 529 + } 530 + 531 + func decode<Output: Decodable>(data: Data, response: URLResponse) throws -> Output { 532 + do { 533 + return try JSONDecoder().decode(Output.self, from: data) 534 + } catch { 535 + throw ATProtoError.decoding("\(error)") 536 + } 537 + } 538 + } 539 + ``` 540 + 541 + - [ ] **Step 5: Run tests to verify they pass** 542 + 543 + ```bash 544 + swift test --filter XRPCClientTests 2>&1 | tail -10 545 + ``` 546 + Expected: 3 tests pass. 547 + 548 + - [ ] **Step 6: Commit** 549 + 550 + ```bash 551 + git add Packages/ATProto/Sources/ATProto/Support Packages/ATProto/Sources/ATProto/XRPC Packages/ATProto/Tests/ATProtoTests/XRPCClientTests.swift 552 + git commit -m "feat(atproto): add XRPCClient GET with envelope decoding" 553 + ``` 554 + 555 + --- 556 + 557 + ### Task 4: XRPCClient POST with JSON body 558 + 559 + **Files:** 560 + - Modify: `Sources/ATProto/XRPC/XRPCClient.swift` (add `post<Input, Output>`) 561 + - Modify: `Tests/ATProtoTests/XRPCClientTests.swift` (add POST tests) 562 + 563 + - [ ] **Step 1: Add failing POST test** 564 + 565 + Append to `Tests/ATProtoTests/XRPCClientTests.swift` inside the `XRPCClientTests` suite: 566 + 567 + ```swift 568 + @Test("POST sends JSON body and decodes response") 569 + func postSuccess() async throws { 570 + struct In: Encodable { let name: String } 571 + struct Out: Decodable, Equatable { let ok: Bool } 572 + 573 + let session = URLProtocolStub.install { request in 574 + #expect(request.httpMethod == "POST") 575 + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") 576 + // URLProtocol receives the body as a stream; just check headers + path here. 577 + #expect(request.url?.path == "/xrpc/com.example.create") 578 + return .json(#"{"ok":true}"#) 579 + } 580 + defer { URLProtocolStub.reset() } 581 + 582 + let client = XRPCClient( 583 + service: URL(string: "https://bsky.social")!, 584 + session: session, 585 + auth: AnonymousAuthTokenProvider() 586 + ) 587 + let result: Out = try await client.post( 588 + nsid: "com.example.create", 589 + input: In(name: "hi") 590 + ) 591 + #expect(result == Out(ok: true)) 592 + } 593 + ``` 594 + 595 + - [ ] **Step 2: Run to verify failure** 596 + 597 + ```bash 598 + swift test --filter XRPCClientTests/postSuccess 2>&1 | tail -10 599 + ``` 600 + Expected: FAIL with "value of type 'XRPCClient' has no member 'post'". 601 + 602 + - [ ] **Step 3: Add POST method to XRPCClient** 603 + 604 + Append to `Sources/ATProto/XRPC/XRPCClient.swift` inside the `XRPCClient` struct: 605 + 606 + ```swift 607 + public func post<Input: Encodable, Output: Decodable>( 608 + nsid: String, 609 + input: Input, 610 + params: [String: String]? = nil 611 + ) async throws -> Output { 612 + let url = try buildURL(nsid: nsid, params: params) 613 + var request = URLRequest(url: url) 614 + request.httpMethod = "POST" 615 + request.addValue("application/json", forHTTPHeaderField: "Content-Type") 616 + request.httpBody = try JSONEncoder().encode(input) 617 + return try await send(request: &request) 618 + } 619 + 620 + public func postRaw( 621 + nsid: String, 622 + body: Data, 623 + contentType: String, 624 + params: [String: String]? = nil 625 + ) async throws -> (Data, URLResponse) { 626 + let url = try buildURL(nsid: nsid, params: params) 627 + var request = URLRequest(url: url) 628 + request.httpMethod = "POST" 629 + request.addValue(contentType, forHTTPHeaderField: "Content-Type") 630 + request.httpBody = body 631 + return try await sendRaw(request: &request) 632 + } 633 + ``` 634 + 635 + - [ ] **Step 4: Run to verify pass** 636 + 637 + ```bash 638 + swift test --filter XRPCClientTests 2>&1 | tail -10 639 + ``` 640 + Expected: 4 tests pass. 641 + 642 + - [ ] **Step 5: Commit** 643 + 644 + ```bash 645 + git add Packages/ATProto/Sources/ATProto/XRPC/XRPCClient.swift Packages/ATProto/Tests/ATProtoTests/XRPCClientTests.swift 646 + git commit -m "feat(atproto): add XRPCClient POST with JSON + raw body" 647 + ``` 648 + 649 + --- 650 + 651 + ### Task 5: Typed endpoint — describeRepo 652 + 653 + **Files:** 654 + - Create: `Sources/ATProto/XRPC/AnyDecodable.swift` 655 + - Create: `Sources/ATProto/XRPC/DescribeRepoOutput.swift` 656 + - Create: `Sources/ATProto/XRPC/XRPCEndpoints.swift` 657 + - Create: `Tests/ATProtoTests/XRPCEndpointsTests.swift` 658 + 659 + - [ ] **Step 1: Write the failing test** 660 + 661 + Create `Tests/ATProtoTests/XRPCEndpointsTests.swift`: 662 + 663 + ```swift 664 + import Foundation 665 + import Testing 666 + @testable import ATProto 667 + 668 + @Suite("XRPC typed endpoints", .serialized) 669 + struct XRPCEndpointsTests { 670 + func makeClient(handler: @escaping (URLRequest) -> URLProtocolStub.Response) -> XRPCClient { 671 + let session = URLProtocolStub.install(handler: handler) 672 + return XRPCClient( 673 + service: URL(string: "https://bsky.social")!, 674 + session: session, 675 + auth: AnonymousAuthTokenProvider() 676 + ) 677 + } 678 + 679 + @Test("describeRepo returns collections + handle") 680 + func describeRepo() async throws { 681 + let client = makeClient { request in 682 + #expect(request.url?.path == "/xrpc/com.atproto.repo.describeRepo") 683 + #expect(request.url?.query?.contains("repo=did:plc:abc") == true) 684 + return .json(#""" 685 + { 686 + "handle": "nate.natemoo.re", 687 + "did": "did:plc:abc", 688 + "didDoc": {"id":"did:plc:abc"}, 689 + "collections": ["app.bsky.feed.post", "app.bsky.actor.profile"], 690 + "handleIsCorrect": true 691 + } 692 + """#) 693 + } 694 + defer { URLProtocolStub.reset() } 695 + 696 + let result = try await client.describeRepo(repo: "did:plc:abc") 697 + #expect(result.handle == "nate.natemoo.re") 698 + #expect(result.did == "did:plc:abc") 699 + #expect(result.collections == ["app.bsky.feed.post", "app.bsky.actor.profile"]) 700 + } 701 + } 702 + ``` 703 + 704 + - [ ] **Step 2: Run to verify failure** 705 + 706 + ```bash 707 + swift test --filter XRPCEndpointsTests 2>&1 | tail -10 708 + ``` 709 + Expected: FAIL with "value of type 'XRPCClient' has no member 'describeRepo'". 710 + 711 + - [ ] **Step 3: Create the AnyDecodable helper** 712 + 713 + Create `Sources/ATProto/XRPC/AnyDecodable.swift`: 714 + 715 + ```swift 716 + import Foundation 717 + 718 + /// Helper to decode arbitrary JSON values into `Any` for pass-through storage. 719 + /// Used by XRPC outputs that carry opaque record values through to callers. 720 + struct AnyDecodable: Decodable { 721 + let value: Any 722 + 723 + init(from decoder: Decoder) throws { 724 + let c = try decoder.singleValueContainer() 725 + if c.decodeNil() { 726 + value = NSNull() 727 + } else if let b = try? c.decode(Bool.self) { 728 + value = b 729 + } else if let i = try? c.decode(Int64.self) { 730 + value = i 731 + } else if let d = try? c.decode(Double.self) { 732 + value = d 733 + } else if let s = try? c.decode(String.self) { 734 + value = s 735 + } else if let arr = try? c.decode([AnyDecodable].self) { 736 + value = arr.map(\.value) 737 + } else if let dict = try? c.decode([String: AnyDecodable].self) { 738 + value = dict.mapValues(\.value) 739 + } else { 740 + throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unsupported JSON value") 741 + } 742 + } 743 + } 744 + ``` 745 + 746 + - [ ] **Step 4: Create the output type** 747 + 748 + Create `Sources/ATProto/XRPC/DescribeRepoOutput.swift`: 749 + 750 + ```swift 751 + import Foundation 752 + 753 + public struct DescribeRepoOutput: Decodable, Sendable, Equatable { 754 + public let handle: String 755 + public let did: String 756 + public let collections: [String] 757 + public let handleIsCorrect: Bool? 758 + 759 + // didDoc is JSON-any; keep it as a raw Data blob so callers can parse only 760 + // the pieces they need (avoids modelling the entire DID-core schema). 761 + public let didDoc: Data? 762 + 763 + enum CodingKeys: String, CodingKey { 764 + case handle, did, collections, handleIsCorrect, didDoc 765 + } 766 + 767 + public init(from decoder: Decoder) throws { 768 + let c = try decoder.container(keyedBy: CodingKeys.self) 769 + handle = try c.decode(String.self, forKey: .handle) 770 + did = try c.decode(String.self, forKey: .did) 771 + collections = try c.decode([String].self, forKey: .collections) 772 + handleIsCorrect = try c.decodeIfPresent(Bool.self, forKey: .handleIsCorrect) 773 + if c.contains(.didDoc) { 774 + let any = try c.decode(AnyDecodable.self, forKey: .didDoc) 775 + didDoc = try JSONSerialization.data(withJSONObject: any.value) 776 + } else { 777 + didDoc = nil 778 + } 779 + } 780 + } 781 + ``` 782 + 783 + - [ ] **Step 5: Create the endpoint extension** 784 + 785 + Create `Sources/ATProto/XRPC/XRPCEndpoints.swift`: 786 + 787 + ```swift 788 + import Foundation 789 + 790 + public extension XRPCClient { 791 + func describeRepo(repo: String) async throws -> DescribeRepoOutput { 792 + try await get(nsid: "com.atproto.repo.describeRepo", params: ["repo": repo]) 793 + } 794 + } 795 + ``` 796 + 797 + - [ ] **Step 6: Run to verify pass** 798 + 799 + ```bash 800 + swift test --filter XRPCEndpointsTests 2>&1 | tail -10 801 + ``` 802 + Expected: 1 test passes. 803 + 804 + - [ ] **Step 7: Commit** 805 + 806 + ```bash 807 + git add Packages/ATProto/Sources/ATProto/XRPC/AnyDecodable.swift Packages/ATProto/Sources/ATProto/XRPC/DescribeRepoOutput.swift Packages/ATProto/Sources/ATProto/XRPC/XRPCEndpoints.swift Packages/ATProto/Tests/ATProtoTests/XRPCEndpointsTests.swift 808 + git commit -m "feat(atproto): add describeRepo typed endpoint" 809 + ``` 810 + 811 + --- 812 + 813 + ### Task 6: Typed endpoint — listRecords 814 + 815 + **Files:** 816 + - Create: `Sources/ATProto/XRPC/ListRecordsOutput.swift` 817 + - Modify: `Sources/ATProto/XRPC/XRPCEndpoints.swift` 818 + - Modify: `Tests/ATProtoTests/XRPCEndpointsTests.swift` 819 + 820 + - [ ] **Step 1: Add failing test** 821 + 822 + Append to the `XRPCEndpointsTests` suite: 823 + 824 + ```swift 825 + @Test("listRecords returns paged records + cursor") 826 + func listRecords() async throws { 827 + let client = makeClient { request in 828 + #expect(request.url?.path == "/xrpc/com.atproto.repo.listRecords") 829 + let q = request.url?.query ?? "" 830 + #expect(q.contains("repo=did:plc:abc")) 831 + #expect(q.contains("collection=app.bsky.feed.post")) 832 + #expect(q.contains("limit=50")) 833 + return .json(#""" 834 + { 835 + "records": [ 836 + {"uri":"at://did:plc:abc/app.bsky.feed.post/3l5xq2abc", 837 + "cid":"bafyreibyvxuebctujncksacu3qfxsv6pzm7z67vfrftndn6pzhknf7g5me", 838 + "value":{"$type":"app.bsky.feed.post","text":"hi"}} 839 + ], 840 + "cursor": "next-page" 841 + } 842 + """#) 843 + } 844 + defer { URLProtocolStub.reset() } 845 + 846 + let out = try await client.listRecords( 847 + repo: "did:plc:abc", 848 + collection: "app.bsky.feed.post", 849 + limit: 50, 850 + cursor: nil, 851 + reverse: nil 852 + ) 853 + #expect(out.records.count == 1) 854 + #expect(out.records[0].uri == "at://did:plc:abc/app.bsky.feed.post/3l5xq2abc") 855 + #expect(out.cursor == "next-page") 856 + } 857 + ``` 858 + 859 + - [ ] **Step 2: Run to verify failure** 860 + 861 + ```bash 862 + swift test --filter XRPCEndpointsTests/listRecords 2>&1 | tail -10 863 + ``` 864 + Expected: FAIL. 865 + 866 + - [ ] **Step 3: Create output type** 867 + 868 + Create `Sources/ATProto/XRPC/ListRecordsOutput.swift`: 869 + 870 + ```swift 871 + import Foundation 872 + 873 + public struct ListRecordsOutput: Decodable, Sendable { 874 + public struct Record: Decodable, Sendable { 875 + public let uri: String 876 + public let cid: String 877 + /// Raw JSON bytes for the record value. Callers pretty-print or re-encode. 878 + public let value: Data 879 + 880 + enum CodingKeys: String, CodingKey { case uri, cid, value } 881 + 882 + public init(from decoder: Decoder) throws { 883 + let c = try decoder.container(keyedBy: CodingKeys.self) 884 + uri = try c.decode(String.self, forKey: .uri) 885 + cid = try c.decode(String.self, forKey: .cid) 886 + let any = try c.decode(AnyDecodable.self, forKey: .value) 887 + value = try JSONSerialization.data(withJSONObject: any.value, options: [.prettyPrinted, .sortedKeys]) 888 + } 889 + } 890 + 891 + public let records: [Record] 892 + public let cursor: String? 893 + } 894 + ``` 895 + 896 + - [ ] **Step 4: Add endpoint** 897 + 898 + Append to `Sources/ATProto/XRPC/XRPCEndpoints.swift`: 899 + 900 + ```swift 901 + public extension XRPCClient { 902 + func listRecords( 903 + repo: String, 904 + collection: String, 905 + limit: Int? = nil, 906 + cursor: String? = nil, 907 + reverse: Bool? = nil 908 + ) async throws -> ListRecordsOutput { 909 + var params: [String: String] = ["repo": repo, "collection": collection] 910 + if let limit { params["limit"] = String(limit) } 911 + if let cursor { params["cursor"] = cursor } 912 + if let reverse { params["reverse"] = reverse ? "true" : "false" } 913 + return try await get(nsid: "com.atproto.repo.listRecords", params: params) 914 + } 915 + } 916 + ``` 917 + 918 + - [ ] **Step 5: Run to verify pass** 919 + 920 + ```bash 921 + swift test --filter XRPCEndpointsTests 2>&1 | tail -10 922 + ``` 923 + Expected: 2 tests pass. 924 + 925 + - [ ] **Step 6: Commit** 926 + 927 + ```bash 928 + git add Packages/ATProto/Sources/ATProto/XRPC/ListRecordsOutput.swift Packages/ATProto/Sources/ATProto/XRPC/XRPCEndpoints.swift Packages/ATProto/Tests/ATProtoTests/XRPCEndpointsTests.swift 929 + git commit -m "feat(atproto): add listRecords typed endpoint" 930 + ``` 931 + 932 + --- 933 + 934 + ### Task 7: Typed endpoint — getRecord 935 + 936 + **Files:** 937 + - Create: `Sources/ATProto/XRPC/GetRecordOutput.swift` 938 + - Modify: `Sources/ATProto/XRPC/XRPCEndpoints.swift` 939 + - Modify: `Tests/ATProtoTests/XRPCEndpointsTests.swift` 940 + 941 + - [ ] **Step 1: Add failing test** 942 + 943 + Append to the `XRPCEndpointsTests` suite: 944 + 945 + ```swift 946 + @Test("getRecord returns uri + cid + pretty value bytes") 947 + func getRecord() async throws { 948 + let client = makeClient { request in 949 + #expect(request.url?.path == "/xrpc/com.atproto.repo.getRecord") 950 + return .json(#""" 951 + { 952 + "uri":"at://did:plc:abc/app.bsky.feed.post/3l5xq2abc", 953 + "cid":"bafyreibyvxuebctujncksacu3qfxsv6pzm7z67vfrftndn6pzhknf7g5me", 954 + "value":{"$type":"app.bsky.feed.post","text":"hello","createdAt":"2026-04-17T00:00:00Z"} 955 + } 956 + """#) 957 + } 958 + defer { URLProtocolStub.reset() } 959 + 960 + let out = try await client.getRecord( 961 + repo: "did:plc:abc", 962 + collection: "app.bsky.feed.post", 963 + rkey: "3l5xq2abc", 964 + cid: nil 965 + ) 966 + #expect(out.uri == "at://did:plc:abc/app.bsky.feed.post/3l5xq2abc") 967 + let pretty = String(data: out.value, encoding: .utf8)! 968 + #expect(pretty.contains("\"hello\"")) 969 + } 970 + ``` 971 + 972 + - [ ] **Step 2: Run to verify failure** 973 + 974 + ```bash 975 + swift test --filter XRPCEndpointsTests/getRecord 2>&1 | tail -10 976 + ``` 977 + Expected: FAIL. 978 + 979 + - [ ] **Step 3: Create output type** 980 + 981 + Create `Sources/ATProto/XRPC/GetRecordOutput.swift`: 982 + 983 + ```swift 984 + import Foundation 985 + 986 + public struct GetRecordOutput: Decodable, Sendable { 987 + public let uri: String 988 + public let cid: String? 989 + public let value: Data 990 + 991 + enum CodingKeys: String, CodingKey { case uri, cid, value } 992 + 993 + public init(from decoder: Decoder) throws { 994 + let c = try decoder.container(keyedBy: CodingKeys.self) 995 + uri = try c.decode(String.self, forKey: .uri) 996 + cid = try c.decodeIfPresent(String.self, forKey: .cid) 997 + let any = try c.decode(AnyDecodable.self, forKey: .value) 998 + value = try JSONSerialization.data(withJSONObject: any.value, options: [.prettyPrinted, .sortedKeys]) 999 + } 1000 + } 1001 + ``` 1002 + 1003 + - [ ] **Step 4: Add endpoint** 1004 + 1005 + Append to `Sources/ATProto/XRPC/XRPCEndpoints.swift`: 1006 + 1007 + ```swift 1008 + public extension XRPCClient { 1009 + func getRecord( 1010 + repo: String, 1011 + collection: String, 1012 + rkey: String, 1013 + cid: String? = nil 1014 + ) async throws -> GetRecordOutput { 1015 + var params: [String: String] = ["repo": repo, "collection": collection, "rkey": rkey] 1016 + if let cid { params["cid"] = cid } 1017 + return try await get(nsid: "com.atproto.repo.getRecord", params: params) 1018 + } 1019 + } 1020 + ``` 1021 + 1022 + - [ ] **Step 5: Run to verify pass** 1023 + 1024 + ```bash 1025 + swift test --filter XRPCEndpointsTests 2>&1 | tail -10 1026 + ``` 1027 + Expected: 3 tests pass. 1028 + 1029 + - [ ] **Step 6: Commit** 1030 + 1031 + ```bash 1032 + git add Packages/ATProto/Sources/ATProto/XRPC/GetRecordOutput.swift Packages/ATProto/Sources/ATProto/XRPC/XRPCEndpoints.swift Packages/ATProto/Tests/ATProtoTests/XRPCEndpointsTests.swift 1033 + git commit -m "feat(atproto): add getRecord typed endpoint" 1034 + ``` 1035 + 1036 + --- 1037 + 1038 + ### Task 8: Typed endpoint — listBlobs 1039 + 1040 + **Files:** 1041 + - Create: `Sources/ATProto/XRPC/ListBlobsOutput.swift` 1042 + - Modify: `Sources/ATProto/XRPC/XRPCEndpoints.swift` 1043 + - Modify: `Tests/ATProtoTests/XRPCEndpointsTests.swift` 1044 + 1045 + - [ ] **Step 1: Add failing test** 1046 + 1047 + Append to the `XRPCEndpointsTests` suite: 1048 + 1049 + ```swift 1050 + @Test("listBlobs returns CIDs + cursor") 1051 + func listBlobs() async throws { 1052 + let client = makeClient { request in 1053 + #expect(request.url?.path == "/xrpc/com.atproto.sync.listBlobs") 1054 + let q = request.url?.query ?? "" 1055 + #expect(q.contains("did=did:plc:abc")) 1056 + #expect(q.contains("limit=500")) 1057 + return .json(#"{"cids":["bafkreia","bafkreib"],"cursor":"c1"}"#) 1058 + } 1059 + defer { URLProtocolStub.reset() } 1060 + 1061 + let out = try await client.listBlobs(did: "did:plc:abc", limit: 500, cursor: nil, since: nil) 1062 + #expect(out.cids == ["bafkreia", "bafkreib"]) 1063 + #expect(out.cursor == "c1") 1064 + } 1065 + ``` 1066 + 1067 + - [ ] **Step 2: Run to verify failure** 1068 + 1069 + ```bash 1070 + swift test --filter XRPCEndpointsTests/listBlobs 2>&1 | tail -10 1071 + ``` 1072 + Expected: FAIL. 1073 + 1074 + - [ ] **Step 3: Create output type** 1075 + 1076 + Create `Sources/ATProto/XRPC/ListBlobsOutput.swift`: 1077 + 1078 + ```swift 1079 + import Foundation 1080 + 1081 + public struct ListBlobsOutput: Decodable, Sendable, Equatable { 1082 + public let cids: [String] 1083 + public let cursor: String? 1084 + } 1085 + ``` 1086 + 1087 + - [ ] **Step 4: Add endpoint** 1088 + 1089 + Append to `Sources/ATProto/XRPC/XRPCEndpoints.swift`: 1090 + 1091 + ```swift 1092 + public extension XRPCClient { 1093 + func listBlobs( 1094 + did: String, 1095 + limit: Int? = nil, 1096 + cursor: String? = nil, 1097 + since: String? = nil 1098 + ) async throws -> ListBlobsOutput { 1099 + var params: [String: String] = ["did": did] 1100 + if let limit { params["limit"] = String(limit) } 1101 + if let cursor { params["cursor"] = cursor } 1102 + if let since { params["since"] = since } 1103 + return try await get(nsid: "com.atproto.sync.listBlobs", params: params) 1104 + } 1105 + } 1106 + ``` 1107 + 1108 + - [ ] **Step 5: Run to verify pass** 1109 + 1110 + ```bash 1111 + swift test --filter XRPCEndpointsTests 2>&1 | tail -10 1112 + ``` 1113 + Expected: 4 tests pass. 1114 + 1115 + - [ ] **Step 6: Commit** 1116 + 1117 + ```bash 1118 + git add Packages/ATProto/Sources/ATProto/XRPC/ListBlobsOutput.swift Packages/ATProto/Sources/ATProto/XRPC/XRPCEndpoints.swift Packages/ATProto/Tests/ATProtoTests/XRPCEndpointsTests.swift 1119 + git commit -m "feat(atproto): add listBlobs typed endpoint" 1120 + ``` 1121 + 1122 + --- 1123 + 1124 + ### Task 9: Typed endpoint — getBlob (raw bytes) 1125 + 1126 + **Files:** 1127 + - Modify: `Sources/ATProto/XRPC/XRPCEndpoints.swift` 1128 + - Modify: `Tests/ATProtoTests/XRPCEndpointsTests.swift` 1129 + 1130 + - [ ] **Step 1: Add failing test** 1131 + 1132 + Append to the `XRPCEndpointsTests` suite: 1133 + 1134 + ```swift 1135 + @Test("getBlob returns raw bytes + MIME from Content-Type") 1136 + func getBlob() async throws { 1137 + let bytes = Data([0xFF, 0xD8, 0xFF, 0xE0]) // JPEG magic 1138 + let client = makeClient { request in 1139 + #expect(request.url?.path == "/xrpc/com.atproto.sync.getBlob") 1140 + let q = request.url?.query ?? "" 1141 + #expect(q.contains("did=did:plc:abc")) 1142 + #expect(q.contains("cid=bafkreia")) 1143 + return .bytes(bytes, contentType: "image/jpeg") 1144 + } 1145 + defer { URLProtocolStub.reset() } 1146 + 1147 + let blob = try await client.getBlob(did: "did:plc:abc", cid: "bafkreia") 1148 + #expect(blob.data == bytes) 1149 + #expect(blob.mimeType == "image/jpeg") 1150 + } 1151 + ``` 1152 + 1153 + - [ ] **Step 2: Run to verify failure** 1154 + 1155 + ```bash 1156 + swift test --filter XRPCEndpointsTests/getBlob 2>&1 | tail -10 1157 + ``` 1158 + Expected: FAIL. 1159 + 1160 + - [ ] **Step 3: Add endpoint + return type** 1161 + 1162 + Append to `Sources/ATProto/XRPC/XRPCEndpoints.swift`: 1163 + 1164 + ```swift 1165 + public struct BlobDownload: Sendable, Equatable { 1166 + public let data: Data 1167 + public let mimeType: String? 1168 + } 1169 + 1170 + public extension XRPCClient { 1171 + func getBlob(did: String, cid: String) async throws -> BlobDownload { 1172 + let (data, response) = try await getBytes( 1173 + nsid: "com.atproto.sync.getBlob", 1174 + params: ["did": did, "cid": cid] 1175 + ) 1176 + let mime = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Type") 1177 + return BlobDownload(data: data, mimeType: mime) 1178 + } 1179 + } 1180 + ``` 1181 + 1182 + - [ ] **Step 4: Run to verify pass** 1183 + 1184 + ```bash 1185 + swift test --filter XRPCEndpointsTests 2>&1 | tail -10 1186 + ``` 1187 + Expected: 5 tests pass. 1188 + 1189 + - [ ] **Step 5: Commit** 1190 + 1191 + ```bash 1192 + git add Packages/ATProto/Sources/ATProto/XRPC/XRPCEndpoints.swift Packages/ATProto/Tests/ATProtoTests/XRPCEndpointsTests.swift 1193 + git commit -m "feat(atproto): add getBlob typed endpoint with MIME extraction" 1194 + ``` 1195 + 1196 + --- 1197 + 1198 + ### Task 10: Handle → DID resolver 1199 + 1200 + **Files:** 1201 + - Create: `Sources/ATProto/Identity/HandleResolver.swift` 1202 + - Create: `Tests/ATProtoTests/HandleResolverTests.swift` 1203 + 1204 + atproto handle resolution spec allows either DNS TXT on `_atproto.<handle>` or 1205 + HTTPS `.well-known/atproto-did`. Per the parent design, the FSKit extension 1206 + can't reliably do raw DNS inside the sandbox, so we use DNS-over-HTTPS 1207 + (Cloudflare `https://cloudflare-dns.com/dns-query` with `application/dns-json` 1208 + response). If DoH fails or doesn't return a TXT record, fall back to HTTPS 1209 + well-known. 1210 + 1211 + - [ ] **Step 1: Write failing test** 1212 + 1213 + Create `Tests/ATProtoTests/HandleResolverTests.swift`: 1214 + 1215 + ```swift 1216 + import Foundation 1217 + import Testing 1218 + @testable import ATProto 1219 + 1220 + @Suite("HandleResolver", .serialized) 1221 + struct HandleResolverTests { 1222 + @Test("resolves via DoH TXT record") 1223 + func dohSuccess() async throws { 1224 + let session = URLProtocolStub.install { request in 1225 + let q = request.url?.query ?? "" 1226 + if request.url?.host == "cloudflare-dns.com" && q.contains("name=_atproto.nate.natemoo.re") { 1227 + // Minimal DoH JSON response 1228 + return .json(#""" 1229 + {"Status":0,"Answer":[{"name":"_atproto.nate.natemoo.re","type":16,"TTL":300,"data":"\"did=did:plc:abc\""}]} 1230 + """#) 1231 + } 1232 + return URLProtocolStub.Response(statusCode: 500, headers: [:], body: Data()) 1233 + } 1234 + defer { URLProtocolStub.reset() } 1235 + 1236 + let resolver = HandleResolver(session: session) 1237 + let did = try await resolver.resolve(handle: Handle("nate.natemoo.re")!) 1238 + #expect(did.rawValue == "did:plc:abc") 1239 + } 1240 + 1241 + @Test("falls back to .well-known when DoH empty") 1242 + func wellKnownFallback() async throws { 1243 + let session = URLProtocolStub.install { request in 1244 + if request.url?.host == "cloudflare-dns.com" { 1245 + return .json(#"{"Status":0,"Answer":[]}"#) 1246 + } 1247 + if request.url?.host == "nate.natemoo.re", request.url?.path == "/.well-known/atproto-did" { 1248 + return .text("did:plc:xyz\n") 1249 + } 1250 + return URLProtocolStub.Response(statusCode: 500, headers: [:], body: Data()) 1251 + } 1252 + defer { URLProtocolStub.reset() } 1253 + 1254 + let resolver = HandleResolver(session: session) 1255 + let did = try await resolver.resolve(handle: Handle("nate.natemoo.re")!) 1256 + #expect(did.rawValue == "did:plc:xyz") 1257 + } 1258 + 1259 + @Test("throws invalidIdentity when both strategies fail") 1260 + func bothFail() async { 1261 + let session = URLProtocolStub.install { _ in 1262 + URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 1263 + } 1264 + defer { URLProtocolStub.reset() } 1265 + 1266 + let resolver = HandleResolver(session: session) 1267 + await #expect(throws: ATProtoError.self) { 1268 + try await resolver.resolve(handle: Handle("ghost.example.com")!) 1269 + } 1270 + } 1271 + } 1272 + ``` 1273 + 1274 + - [ ] **Step 2: Run to verify failure** 1275 + 1276 + ```bash 1277 + swift test --filter HandleResolverTests 2>&1 | tail -10 1278 + ``` 1279 + Expected: FAIL with "cannot find 'HandleResolver' in scope". 1280 + 1281 + - [ ] **Step 3: Implement resolver** 1282 + 1283 + Create `Sources/ATProto/Identity/HandleResolver.swift`: 1284 + 1285 + ```swift 1286 + import Foundation 1287 + 1288 + public struct HandleResolver: Sendable { 1289 + let session: URLSession 1290 + let dohEndpoint: URL 1291 + 1292 + public init( 1293 + session: URLSession = .shared, 1294 + dohEndpoint: URL = URL(string: "https://cloudflare-dns.com/dns-query")! 1295 + ) { 1296 + self.session = session 1297 + self.dohEndpoint = dohEndpoint 1298 + } 1299 + 1300 + public func resolve(handle: Handle) async throws -> DID { 1301 + if let did = try? await resolveViaDoH(handle: handle) { 1302 + return did 1303 + } 1304 + if let did = try? await resolveViaWellKnown(handle: handle) { 1305 + return did 1306 + } 1307 + throw ATProtoError.invalidIdentity("could not resolve handle \(handle)") 1308 + } 1309 + 1310 + func resolveViaDoH(handle: Handle) async throws -> DID { 1311 + var comps = URLComponents(url: dohEndpoint, resolvingAgainstBaseURL: false)! 1312 + comps.queryItems = [ 1313 + URLQueryItem(name: "name", value: "_atproto.\(handle.rawValue)"), 1314 + URLQueryItem(name: "type", value: "TXT"), 1315 + ] 1316 + var request = URLRequest(url: comps.url!) 1317 + request.addValue("application/dns-json", forHTTPHeaderField: "Accept") 1318 + let (data, _) = try await session.data(for: request) 1319 + struct DoHResponse: Decodable { let Answer: [Answer]? 1320 + struct Answer: Decodable { let data: String } 1321 + } 1322 + let decoded = try JSONDecoder().decode(DoHResponse.self, from: data) 1323 + for answer in decoded.Answer ?? [] { 1324 + // TXT data is JSON-escaped with surrounding quotes; strip them. 1325 + let s = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) 1326 + if let did = Self.parseDIDFromTXT(s) { 1327 + return did 1328 + } 1329 + } 1330 + throw ATProtoError.invalidIdentity("no atproto TXT record for \(handle)") 1331 + } 1332 + 1333 + func resolveViaWellKnown(handle: Handle) async throws -> DID { 1334 + let url = URL(string: "https://\(handle.rawValue)/.well-known/atproto-did")! 1335 + let (data, response) = try await session.data(for: URLRequest(url: url)) 1336 + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { 1337 + throw ATProtoError.invalidIdentity("well-known returned non-2xx for \(handle)") 1338 + } 1339 + let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) 1340 + guard let did = DID(text) else { 1341 + throw ATProtoError.invalidIdentity("well-known body is not a DID: \(text)") 1342 + } 1343 + return did 1344 + } 1345 + 1346 + /// Parses `did=did:plc:abc` from a TXT record body. 1347 + static func parseDIDFromTXT(_ s: String) -> DID? { 1348 + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) 1349 + guard trimmed.hasPrefix("did=") else { return nil } 1350 + return DID(String(trimmed.dropFirst("did=".count))) 1351 + } 1352 + } 1353 + ``` 1354 + 1355 + - [ ] **Step 4: Run to verify pass** 1356 + 1357 + ```bash 1358 + swift test --filter HandleResolverTests 2>&1 | tail -10 1359 + ``` 1360 + Expected: 3 tests pass. 1361 + 1362 + - [ ] **Step 5: Commit** 1363 + 1364 + ```bash 1365 + git add Packages/ATProto/Sources/ATProto/Identity/HandleResolver.swift Packages/ATProto/Tests/ATProtoTests/HandleResolverTests.swift 1366 + git commit -m "feat(atproto): resolve handles via DoH TXT + .well-known fallback" 1367 + ``` 1368 + 1369 + --- 1370 + 1371 + ### Task 11: DID → doc resolver 1372 + 1373 + **Files:** 1374 + - Create: `Sources/ATProto/Identity/DIDDocument.swift` 1375 + - Create: `Sources/ATProto/Identity/DIDResolver.swift` 1376 + - Create: `Tests/ATProtoTests/DIDResolverTests.swift` 1377 + 1378 + - [ ] **Step 1: Write failing test** 1379 + 1380 + Create `Tests/ATProtoTests/DIDResolverTests.swift`: 1381 + 1382 + ```swift 1383 + import Foundation 1384 + import Testing 1385 + @testable import ATProto 1386 + 1387 + @Suite("DIDResolver", .serialized) 1388 + struct DIDResolverTests { 1389 + @Test("resolves did:plc via plc.directory") 1390 + func plcResolve() async throws { 1391 + let session = URLProtocolStub.install { request in 1392 + if request.url?.absoluteString == "https://plc.directory/did:plc:abc" { 1393 + return .json(#""" 1394 + { 1395 + "id":"did:plc:abc", 1396 + "alsoKnownAs":["at://nate.natemoo.re"], 1397 + "service":[ 1398 + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://bsky.social"} 1399 + ] 1400 + } 1401 + """#) 1402 + } 1403 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 1404 + } 1405 + defer { URLProtocolStub.reset() } 1406 + 1407 + let resolver = DIDResolver(session: session) 1408 + let doc = try await resolver.resolve(did: DID("did:plc:abc")!) 1409 + #expect(doc.id == "did:plc:abc") 1410 + #expect(doc.pdsEndpoint?.absoluteString == "https://bsky.social") 1411 + #expect(doc.handles.first == "nate.natemoo.re") 1412 + } 1413 + 1414 + @Test("resolves did:web via .well-known") 1415 + func webResolve() async throws { 1416 + let session = URLProtocolStub.install { request in 1417 + if request.url?.absoluteString == "https://pdfs.at/.well-known/did.json" { 1418 + return .json(#""" 1419 + { 1420 + "id":"did:web:pdfs.at", 1421 + "service":[ 1422 + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.pdfs.at"} 1423 + ] 1424 + } 1425 + """#) 1426 + } 1427 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 1428 + } 1429 + defer { URLProtocolStub.reset() } 1430 + 1431 + let resolver = DIDResolver(session: session) 1432 + let doc = try await resolver.resolve(did: DID("did:web:pdfs.at")!) 1433 + #expect(doc.pdsEndpoint?.absoluteString == "https://pds.pdfs.at") 1434 + } 1435 + 1436 + @Test("throws when no PDS endpoint in doc") 1437 + func missingPDS() async throws { 1438 + let session = URLProtocolStub.install { _ in 1439 + .json(#"{"id":"did:plc:abc","service":[]}"#) 1440 + } 1441 + defer { URLProtocolStub.reset() } 1442 + 1443 + let resolver = DIDResolver(session: session) 1444 + let doc = try await resolver.resolve(did: DID("did:plc:abc")!) 1445 + #expect(doc.pdsEndpoint == nil) 1446 + } 1447 + } 1448 + ``` 1449 + 1450 + - [ ] **Step 2: Run to verify failure** 1451 + 1452 + ```bash 1453 + swift test --filter DIDResolverTests 2>&1 | tail -10 1454 + ``` 1455 + Expected: FAIL. 1456 + 1457 + - [ ] **Step 3: Implement DIDDocument** 1458 + 1459 + Create `Sources/ATProto/Identity/DIDDocument.swift`: 1460 + 1461 + ```swift 1462 + import Foundation 1463 + 1464 + public struct DIDDocument: Sendable, Equatable { 1465 + public let id: String 1466 + public let alsoKnownAs: [String] 1467 + public let services: [Service] 1468 + 1469 + public struct Service: Sendable, Equatable, Decodable { 1470 + public let id: String 1471 + public let type: String 1472 + public let serviceEndpoint: String 1473 + } 1474 + 1475 + /// Handles parsed from `alsoKnownAs` entries like `at://nate.natemoo.re`. 1476 + public var handles: [String] { 1477 + alsoKnownAs.compactMap { 1478 + $0.hasPrefix("at://") ? String($0.dropFirst("at://".count)) : nil 1479 + } 1480 + } 1481 + 1482 + /// First PDS endpoint service URL, if present. 1483 + public var pdsEndpoint: URL? { 1484 + guard let svc = services.first(where: { $0.type == "AtprotoPersonalDataServer" }) else { 1485 + return nil 1486 + } 1487 + return URL(string: svc.serviceEndpoint) 1488 + } 1489 + } 1490 + 1491 + extension DIDDocument: Decodable { 1492 + enum CodingKeys: String, CodingKey { 1493 + case id, alsoKnownAs, service 1494 + } 1495 + 1496 + public init(from decoder: Decoder) throws { 1497 + let c = try decoder.container(keyedBy: CodingKeys.self) 1498 + id = try c.decode(String.self, forKey: .id) 1499 + alsoKnownAs = try c.decodeIfPresent([String].self, forKey: .alsoKnownAs) ?? [] 1500 + services = try c.decodeIfPresent([Service].self, forKey: .service) ?? [] 1501 + } 1502 + } 1503 + ``` 1504 + 1505 + - [ ] **Step 4: Implement DIDResolver** 1506 + 1507 + Create `Sources/ATProto/Identity/DIDResolver.swift`: 1508 + 1509 + ```swift 1510 + import Foundation 1511 + 1512 + public struct DIDResolver: Sendable { 1513 + let session: URLSession 1514 + let plcDirectory: URL 1515 + 1516 + public init( 1517 + session: URLSession = .shared, 1518 + plcDirectory: URL = URL(string: "https://plc.directory")! 1519 + ) { 1520 + self.session = session 1521 + self.plcDirectory = plcDirectory 1522 + } 1523 + 1524 + public func resolve(did: DID) async throws -> DIDDocument { 1525 + switch did.method { 1526 + case .plc: 1527 + return try await resolvePLC(did: did) 1528 + case .web: 1529 + return try await resolveWeb(did: did) 1530 + case .none: 1531 + throw ATProtoError.invalidIdentity("unsupported DID method: \(did)") 1532 + } 1533 + } 1534 + 1535 + func resolvePLC(did: DID) async throws -> DIDDocument { 1536 + let url = plcDirectory.appendingPathComponent(did.rawValue) 1537 + let (data, response) = try await session.data(for: URLRequest(url: url)) 1538 + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { 1539 + throw ATProtoError.invalidIdentity("plc.directory returned non-2xx for \(did)") 1540 + } 1541 + return try JSONDecoder().decode(DIDDocument.self, from: data) 1542 + } 1543 + 1544 + func resolveWeb(did: DID) async throws -> DIDDocument { 1545 + // did:web:<host>[:path...] → https://<host>/<path>/.well-known/did.json 1546 + let parts = did.rawValue.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false) 1547 + guard parts.count == 3 else { throw ATProtoError.invalidIdentity("malformed did:web \(did)") } 1548 + let rest = parts[2].replacingOccurrences(of: ":", with: "/") 1549 + let url = URL(string: "https://\(rest)/.well-known/did.json") 1550 + guard let url else { throw ATProtoError.invalidIdentity("bad did:web URL for \(did)") } 1551 + let (data, response) = try await session.data(for: URLRequest(url: url)) 1552 + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { 1553 + throw ATProtoError.invalidIdentity("did:web returned non-2xx for \(did)") 1554 + } 1555 + return try JSONDecoder().decode(DIDDocument.self, from: data) 1556 + } 1557 + } 1558 + ``` 1559 + 1560 + - [ ] **Step 5: Run to verify pass** 1561 + 1562 + ```bash 1563 + swift test --filter DIDResolverTests 2>&1 | tail -10 1564 + ``` 1565 + Expected: 3 tests pass. 1566 + 1567 + - [ ] **Step 6: Commit** 1568 + 1569 + ```bash 1570 + git add Packages/ATProto/Sources/ATProto/Identity Packages/ATProto/Tests/ATProtoTests/DIDResolverTests.swift 1571 + git commit -m "feat(atproto): resolve DID docs via PLC directory and did:web" 1572 + ``` 1573 + 1574 + --- 1575 + 1576 + ### Task 12: IdentityResolver orchestrator + cache 1577 + 1578 + **Files:** 1579 + - Create: `Sources/ATProto/Identity/IdentityResolver.swift` 1580 + - Create: `Tests/ATProtoTests/IdentityResolverTests.swift` 1581 + 1582 + - [ ] **Step 1: Write failing test** 1583 + 1584 + Create `Tests/ATProtoTests/IdentityResolverTests.swift`: 1585 + 1586 + ```swift 1587 + import Foundation 1588 + import Testing 1589 + @testable import ATProto 1590 + 1591 + @Suite("IdentityResolver", .serialized) 1592 + struct IdentityResolverTests { 1593 + @Test("resolves handle end-to-end: handle → DID → PDS") 1594 + func endToEnd() async throws { 1595 + let session = URLProtocolStub.install { request in 1596 + switch request.url { 1597 + case let u? where u.host == "cloudflare-dns.com": 1598 + return .json(#""" 1599 + {"Status":0,"Answer":[{"data":"\"did=did:plc:abc\""}]} 1600 + """#) 1601 + case let u? where u.absoluteString == "https://plc.directory/did:plc:abc": 1602 + return .json(#""" 1603 + {"id":"did:plc:abc","alsoKnownAs":["at://nate.natemoo.re"], 1604 + "service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://bsky.social"}]} 1605 + """#) 1606 + default: 1607 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 1608 + } 1609 + } 1610 + defer { URLProtocolStub.reset() } 1611 + 1612 + let resolver = IdentityResolver(session: session) 1613 + let resolved = try await resolver.resolve(handleOrDID: "nate.natemoo.re") 1614 + #expect(resolved.did.rawValue == "did:plc:abc") 1615 + #expect(resolved.pds?.absoluteString == "https://bsky.social") 1616 + } 1617 + 1618 + @Test("caches DID→doc within TTL") 1619 + func cacheHits() async throws { 1620 + let session = URLProtocolStub.install { _ in 1621 + .json(#""" 1622 + {"id":"did:plc:abc", 1623 + "service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://bsky.social"}]} 1624 + """#) 1625 + } 1626 + defer { URLProtocolStub.reset() } 1627 + 1628 + let resolver = IdentityResolver(session: session) 1629 + _ = try await resolver.resolve(did: DID("did:plc:abc")!) 1630 + _ = try await resolver.resolve(did: DID("did:plc:abc")!) 1631 + #expect(URLProtocolStub.recordedRequests.count == 1) 1632 + } 1633 + } 1634 + ``` 1635 + 1636 + - [ ] **Step 2: Run to verify failure** 1637 + 1638 + ```bash 1639 + swift test --filter IdentityResolverTests 2>&1 | tail -10 1640 + ``` 1641 + Expected: FAIL. 1642 + 1643 + - [ ] **Step 3: Implement IdentityResolver** 1644 + 1645 + Create `Sources/ATProto/Identity/IdentityResolver.swift`: 1646 + 1647 + ```swift 1648 + import Foundation 1649 + 1650 + public struct ResolvedIdentity: Sendable, Equatable { 1651 + public let did: DID 1652 + public let handle: Handle? 1653 + public let doc: DIDDocument 1654 + public var pds: URL? { doc.pdsEndpoint } 1655 + } 1656 + 1657 + public actor IdentityResolver { 1658 + let handleResolver: HandleResolver 1659 + let didResolver: DIDResolver 1660 + let ttl: TimeInterval 1661 + 1662 + struct CacheEntry<V> { 1663 + let value: V 1664 + let expiresAt: Date 1665 + } 1666 + 1667 + var didDocs: [DID: CacheEntry<DIDDocument>] = [:] 1668 + var handleToDID: [Handle: CacheEntry<DID>] = [:] 1669 + 1670 + public init( 1671 + session: URLSession = .shared, 1672 + ttl: TimeInterval = 60 * 60 1673 + ) { 1674 + self.handleResolver = HandleResolver(session: session) 1675 + self.didResolver = DIDResolver(session: session) 1676 + self.ttl = ttl 1677 + } 1678 + 1679 + public func resolve(did: DID) async throws -> DIDDocument { 1680 + if let entry = didDocs[did], entry.expiresAt > Date() { 1681 + return entry.value 1682 + } 1683 + let doc = try await didResolver.resolve(did: did) 1684 + didDocs[did] = CacheEntry(value: doc, expiresAt: Date().addingTimeInterval(ttl)) 1685 + return doc 1686 + } 1687 + 1688 + public func resolve(handle: Handle) async throws -> DID { 1689 + if let entry = handleToDID[handle], entry.expiresAt > Date() { 1690 + return entry.value 1691 + } 1692 + let did = try await handleResolver.resolve(handle: handle) 1693 + handleToDID[handle] = CacheEntry(value: did, expiresAt: Date().addingTimeInterval(ttl)) 1694 + return did 1695 + } 1696 + 1697 + public func resolve(handleOrDID: String) async throws -> ResolvedIdentity { 1698 + let did: DID 1699 + let handle: Handle? 1700 + if let parsedDID = DID(handleOrDID) { 1701 + did = parsedDID 1702 + handle = nil 1703 + } else if let parsedHandle = Handle(handleOrDID) { 1704 + handle = parsedHandle 1705 + did = try await resolve(handle: parsedHandle) 1706 + } else { 1707 + throw ATProtoError.invalidIdentity(handleOrDID) 1708 + } 1709 + let doc = try await resolve(did: did) 1710 + return ResolvedIdentity(did: did, handle: handle, doc: doc) 1711 + } 1712 + 1713 + public func invalidate() { 1714 + didDocs.removeAll() 1715 + handleToDID.removeAll() 1716 + } 1717 + } 1718 + ``` 1719 + 1720 + - [ ] **Step 4: Run to verify pass** 1721 + 1722 + ```bash 1723 + swift test --filter IdentityResolverTests 2>&1 | tail -10 1724 + ``` 1725 + Expected: 2 tests pass. 1726 + 1727 + - [ ] **Step 5: Commit** 1728 + 1729 + ```bash 1730 + git add Packages/ATProto/Sources/ATProto/Identity/IdentityResolver.swift Packages/ATProto/Tests/ATProtoTests/IdentityResolverTests.swift 1731 + git commit -m "feat(atproto): add IdentityResolver actor with TTL cache" 1732 + ``` 1733 + 1734 + --- 1735 + 1736 + ### Task 13: Run full suite + sanity-check against real PDS 1737 + 1738 + This task has no new code. It verifies the package holds together and 1739 + optionally exercises the real bsky.social PDS to catch any wire-format drift. 1740 + 1741 + - [ ] **Step 1: Run the whole test suite** 1742 + 1743 + ```bash 1744 + swift test 2>&1 | tail -5 1745 + ``` 1746 + Expected: all tests pass (should be 20+ across Identifiers, Errors, XRPC, 1747 + Endpoints, HandleResolver, DIDResolver, IdentityResolver). 1748 + 1749 + - [ ] **Step 2: Confirm no compiler warnings** 1750 + 1751 + ```bash 1752 + swift build 2>&1 | grep -i warning || echo "no warnings" 1753 + ``` 1754 + Expected: "no warnings". 1755 + 1756 + - [ ] **Step 3: (Optional, not in CI) Live smoke test against bsky.social** 1757 + 1758 + Only worth doing if you want to confirm wire-format drift before shipping. 1759 + Add a temporary executable target called `ATProtoSmoke` to `Package.swift` 1760 + with a `main.swift` that calls `IdentityResolver().resolve(handleOrDID: 1761 + "bsky.app")` and prints the result, run `swift run ATProtoSmoke`, verify 1762 + it prints a PLC DID and the bsky.social PDS URL, then revert the 1763 + `Package.swift` change. This never goes into CI — it hits a live network. 1764 + 1765 + If the smoke run exposed anything (e.g., field-name drift in 1766 + `describeRepo`), fix and commit. Otherwise no action. 1767 + 1768 + --- 1769 + 1770 + ## Self-review notes 1771 + 1772 + Coverage against the parent plan's "atproto client" section: 1773 + - ✅ Identifiers (already shipped before this plan) 1774 + - ✅ Error model + POSIX mapping (Task 1) 1775 + - ✅ XRPC GET/POST (Tasks 3–4) 1776 + - ✅ describeRepo, listRecords, getRecord, listBlobs, getBlob (Tasks 5–9) 1777 + - ✅ Handle→DID resolution (Task 10) 1778 + - ✅ DID→doc + PDS endpoint (Task 11) 1779 + - ✅ IdentityResolver + TTL cache (Task 12) 1780 + - 🔜 Write endpoints (`createRecord`, `putRecord`, `deleteRecord`, 1781 + `applyWrites`, `uploadBlob`) — deferred to the OAuth plan since they 1782 + cannot exercise auth-free 1783 + - 🔜 OAuth 2.1 + DPoP + Keychain — separate plan 1784 + - 🔜 `AuthTokenProvider` DPoP-bound implementation — separate plan 1785 + - 🔜 On-disk identity cache (`~/Library/Caches/...`) — not needed yet; add 1786 + when FSKit extension starts reusing resolutions across launches 1787 + 1788 + After this plan, the ATProto package supports anonymous reads against any 1789 + public atproto PDS. The next plan (OAuth) makes writes and authenticated 1790 + reads possible by implementing the DPoP-bound `AuthTokenProvider`.