···11+22+# Contributor Covenant Code of Conduct
33+44+## Our Pledge
55+66+We as members, contributors, and leaders pledge to make participation in our
77+community a harassment-free experience for everyone, regardless of age, body
88+size, visible or invisible disability, ethnicity, sex characteristics, gender
99+identity and expression, level of experience, education, socio-economic status,
1010+nationality, personal appearance, race, caste, color, religion, or sexual
1111+identity and orientation.
1212+1313+We pledge to act and interact in ways that contribute to an open, welcoming,
1414+diverse, inclusive, and healthy community.
1515+1616+## Our Standards
1717+1818+Examples of behavior that contributes to a positive environment for our
1919+community include:
2020+2121+* Demonstrating empathy and kindness toward other people
2222+* Being respectful of differing opinions, viewpoints, and experiences
2323+* Giving and gracefully accepting constructive feedback
2424+* Accepting responsibility and apologizing to those affected by our mistakes,
2525+ and learning from the experience
2626+* Focusing on what is best not just for us as individuals, but for the overall
2727+ community
2828+2929+Examples of unacceptable behavior include:
3030+3131+* The use of sexualized language or imagery, and sexual attention or advances of
3232+ any kind
3333+* Trolling, insulting or derogatory comments, and personal or political attacks
3434+* Public or private harassment
3535+* Publishing others' private information, such as a physical or email address,
3636+ without their explicit permission
3737+* Other conduct which could reasonably be considered inappropriate in a
3838+ professional setting
3939+4040+## Enforcement Responsibilities
4141+4242+Community leaders are responsible for clarifying and enforcing our standards of
4343+acceptable behavior and will take appropriate and fair corrective action in
4444+response to any behavior that they deem inappropriate, threatening, offensive,
4545+or harmful.
4646+4747+Community leaders have the right and responsibility to remove, edit, or reject
4848+comments, commits, code, wiki edits, issues, and other contributions that are
4949+not aligned to this Code of Conduct, and will communicate reasons for moderation
5050+decisions when appropriate.
5151+5252+## Scope
5353+5454+This Code of Conduct applies within all community spaces, and also applies when
5555+an individual is officially representing the community in public spaces.
5656+Examples of representing our community include using an official e-mail address,
5757+posting via an official social media account, or acting as an appointed
5858+representative at an online or offline event.
5959+6060+## Enforcement
6161+6262+Instances of abusive, harassing, or otherwise unacceptable behavior may be
6363+reported to the community leaders responsible for enforcement at
6464+contact@sparrowtek.com.
6565+All complaints will be reviewed and investigated promptly and fairly.
6666+6767+All community leaders are obligated to respect the privacy and security of the
6868+reporter of any incident.
6969+7070+## Enforcement Guidelines
7171+7272+Community leaders will follow these Community Impact Guidelines in determining
7373+the consequences for any action they deem in violation of this Code of Conduct:
7474+7575+### 1. Correction
7676+7777+**Community Impact**: Use of inappropriate language or other behavior deemed
7878+unprofessional or unwelcome in the community.
7979+8080+**Consequence**: A private, written warning from community leaders, providing
8181+clarity around the nature of the violation and an explanation of why the
8282+behavior was inappropriate. A public apology may be requested.
8383+8484+### 2. Warning
8585+8686+**Community Impact**: A violation through a single incident or series of
8787+actions.
8888+8989+**Consequence**: A warning with consequences for continued behavior. No
9090+interaction with the people involved, including unsolicited interaction with
9191+those enforcing the Code of Conduct, for a specified period of time. This
9292+includes avoiding interactions in community spaces as well as external channels
9393+like social media. Violating these terms may lead to a temporary or permanent
9494+ban.
9595+9696+### 3. Temporary Ban
9797+9898+**Community Impact**: A serious violation of community standards, including
9999+sustained inappropriate behavior.
100100+101101+**Consequence**: A temporary ban from any sort of interaction or public
102102+communication with the community for a specified period of time. No public or
103103+private interaction with the people involved, including unsolicited interaction
104104+with those enforcing the Code of Conduct, is allowed during this period.
105105+Violating these terms may lead to a permanent ban.
106106+107107+### 4. Permanent Ban
108108+109109+**Community Impact**: Demonstrating a pattern of violation of community
110110+standards, including sustained inappropriate behavior, harassment of an
111111+individual, or aggression toward or disparagement of classes of individuals.
112112+113113+**Consequence**: A permanent ban from any sort of public interaction within the
114114+community.
115115+116116+## Attribution
117117+118118+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
119119+version 2.1, available at
120120+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
121121+122122+Community Impact Guidelines were inspired by
123123+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
124124+125125+For answers to common questions about this code of conduct, see the FAQ at
126126+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
127127+[https://www.contributor-covenant.org/translations][translations].
128128+129129+[homepage]: https://www.contributor-covenant.org
130130+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
131131+[Mozilla CoC]: https://github.com/mozilla/diversity
132132+[FAQ]: https://www.contributor-covenant.org/faq
133133+[translations]: https://www.contributor-covenant.org/translations
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2026 SparrowTek
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
···11+# CoreATProtocol
22+33+A Swift package providing the foundational networking layer for interacting with the [AT Protocol](https://atproto.com) (Authenticated Transfer Protocol). This library handles the core HTTP communication, authentication token management, and request/response encoding required to build AT Protocol clients.
44+55+## Overview
66+77+CoreATProtocol is designed to be protocol-agnostic within the AT Protocol ecosystem. It provides the networking infrastructure that higher-level packages (like [bskyKit](https://tangled.org/@sparrowtek.com/bskyKit) for Bluesky) can build upon to implement specific lexicons.
88+99+### Key Features
1010+1111+- **Modern Swift Concurrency** - Built with Swift 6.2 using async/await and actors for thread-safe operations
1212+- **Global Actor Isolation** - Uses `@APActor` for consistent thread safety across all AT Protocol operations
1313+- **Flexible Network Routing** - Generic `NetworkRouter` that works with any endpoint conforming to `EndpointType`
1414+- **Automatic Token Management** - Built-in support for JWT access/refresh token handling with automatic retry on expiration
1515+- **Multiple Parameter Encodings** - URL, JSON, and combined encoding strategies for request parameters
1616+- **AT Protocol Error Handling** - Typed error responses matching AT Protocol error specifications
1717+- **Testable Architecture** - Protocol-based design allows easy mocking for unit tests
1818+1919+## Requirements
2020+2121+- Swift 6.2+
2222+- iOS 26.0+ / macOS 26.0+ / watchOS 26.0+ / tvOS 26.0+ / Mac Catalyst 26.0+
2323+2424+## Installation
2525+2626+### Swift Package Manager
2727+2828+Add CoreATProtocol to your `Package.swift` dependencies:
2929+3030+```swift
3131+dependencies: [
3232+ .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"),
3333+]
3434+```
3535+3636+Then add it to your target dependencies:
3737+3838+```swift
3939+.target(
4040+ name: "YourTarget",
4141+ dependencies: ["CoreATProtocol"]
4242+),
4343+```
4444+4545+Or in Xcode: File > Add Package Dependencies and enter:
4646+```
4747+https://tangled.org/@sparrowtek.com/CoreATProtocol
4848+```
4949+5050+## Usage
5151+5252+### Initial Setup
5353+5454+Configure the environment with your host URL and authentication tokens:
5555+5656+```swift
5757+import CoreATProtocol
5858+5959+// Setup with host and tokens
6060+await setup(
6161+ hostURL: "https://bsky.social",
6262+ accessJWT: "your-access-token",
6363+ refreshJWT: "your-refresh-token"
6464+)
6565+6666+// Or update tokens later
6767+await updateTokens(access: newAccessToken, refresh: newRefreshToken)
6868+6969+// Change host
7070+await update(hostURL: "https://different-pds.example")
7171+```
7272+7373+### Defining Endpoints
7474+7575+Create endpoints by conforming to `EndpointType`:
7676+7777+```swift
7878+import CoreATProtocol
7979+8080+enum MyEndpoint: EndpointType {
8181+ case getProfile(actor: String)
8282+ case createPost(text: String)
8383+8484+ var baseURL: URL {
8585+ get async {
8686+ URL(string: APEnvironment.current.host ?? "https://bsky.social")!
8787+ }
8888+ }
8989+9090+ var path: String {
9191+ switch self {
9292+ case .getProfile:
9393+ return "/xrpc/app.bsky.actor.getProfile"
9494+ case .createPost:
9595+ return "/xrpc/com.atproto.repo.createRecord"
9696+ }
9797+ }
9898+9999+ var httpMethod: HTTPMethod {
100100+ switch self {
101101+ case .getProfile: return .get
102102+ case .createPost: return .post
103103+ }
104104+ }
105105+106106+ var task: HTTPTask {
107107+ get async {
108108+ switch self {
109109+ case .getProfile(let actor):
110110+ return .requestParameters(encoding: .urlEncoding(parameters: ["actor": actor]))
111111+ case .createPost(let text):
112112+ let body: [String: Any] = ["text": text]
113113+ return .requestParameters(encoding: .jsonEncoding(parameters: body))
114114+ }
115115+ }
116116+ }
117117+118118+ var headers: HTTPHeaders? {
119119+ get async { nil }
120120+ }
121121+}
122122+```
123123+124124+### Making Requests
125125+126126+Use `NetworkRouter` to execute requests:
127127+128128+```swift
129129+@APActor
130130+class MyATClient {
131131+ private let router = NetworkRouter<MyEndpoint>()
132132+133133+ init() {
134134+ router.delegate = APEnvironment.current.routerDelegate
135135+ }
136136+137137+ func getProfile(actor: String) async throws -> ProfileResponse {
138138+ try await router.execute(.getProfile(actor: actor))
139139+ }
140140+}
141141+```
142142+143143+### Custom JSON Decoding
144144+145145+Use the pre-configured AT Protocol decoder for proper date handling:
146146+147147+```swift
148148+let router = NetworkRouter<MyEndpoint>(decoder: .atDecoder)
149149+```
150150+151151+### Error Handling
152152+153153+Handle AT Protocol specific errors:
154154+155155+```swift
156156+do {
157157+ let profile: Profile = try await router.execute(.getProfile(actor: "did:plc:example"))
158158+} catch let error as AtError {
159159+ switch error {
160160+ case .message(let errorMessage):
161161+ print("AT Error: \(errorMessage.error) - \(errorMessage.message ?? "")")
162162+ case .network(let networkError):
163163+ switch networkError {
164164+ case .statusCode(let code, let data):
165165+ print("HTTP \(code?.rawValue ?? 0)")
166166+ case .encodingFailed:
167167+ print("Failed to encode request")
168168+ default:
169169+ print("Network error: \(networkError)")
170170+ }
171171+ }
172172+}
173173+```
174174+175175+## Architecture
176176+177177+### Core Components
178178+179179+| Component | Description |
180180+|-----------|-------------|
181181+| `APActor` | Global actor ensuring thread-safe access to AT Protocol state |
182182+| `APEnvironment` | Singleton holding host URL, tokens, and delegates |
183183+| `NetworkRouter` | Generic router executing typed endpoint requests |
184184+| `EndpointType` | Protocol defining API endpoint requirements |
185185+| `ParameterEncoding` | Enum supporting URL, JSON, and hybrid encoding |
186186+| `AtError` | AT Protocol error types with message parsing |
187187+188188+### Thread Safety
189189+190190+All AT Protocol operations are isolated to `@APActor` ensuring thread-safe access:
191191+192192+```swift
193193+@APActor
194194+public func myFunction() async {
195195+ // Safe access to APEnvironment.current
196196+}
197197+```
198198+199199+## Parameter Encoding Options
200200+201201+```swift
202202+// URL query parameters
203203+.urlEncoding(parameters: ["key": "value"])
204204+205205+// JSON body
206206+.jsonEncoding(parameters: ["key": "value"])
207207+208208+// Pre-encoded JSON data
209209+.jsonDataEncoding(data: jsonData)
210210+211211+// Encodable objects
212212+.jsonEncodableEncoding(encodable: myStruct)
213213+214214+// Combined URL + JSON body
215215+.urlAndJsonEncoding(urlParameters: ["q": "search"], bodyParameters: ["data": "value"])
216216+```
217217+218218+## Related Packages
219219+220220+- **[bskyKit](https://tangled.org/@sparrowtek.com/bskyKit)** - Bluesky-specific lexicon implementations built on CoreATProtocol
221221+222222+## License
223223+224224+This project is licensed under an [MIT license](https://tangled.org/sparrowtek.com/CoreATProtocol/blob/main/LICENSE).
225225+226226+## Contributing
227227+228228+It is always a good idea to discuss before taking on a significant task. That said, I have a strong bias towards enthusiasm. If you are excited about doing something, I'll do my best to get out of your way.
229229+230230+By participating in this project you agree to abide by the [Contributor Code of Conduct](https://tangled.org/sparrowtek.com/CoreATProtocol/blob/main/CODE_OF_CONDUCT.md).
+10
Sources/CoreATProtocol/APActor.swift
···11+//
22+// APActor.swift
33+// CoreATProtocol
44+//
55+// Created by Thomas Rademaker on 10/8/25.
66+//
77+88+@globalActor public actor APActor {
99+ public static let shared = APActor()
1010+}
+26
Sources/CoreATProtocol/APEnvironment.swift
···11+//
22+// APEnvironment.swift
33+// CoreATProtocol
44+//
55+// Created by Thomas Rademaker on 10/10/25.
66+//
77+88+@APActor
99+public class APEnvironment {
1010+ public static var current: APEnvironment = APEnvironment()
1111+1212+ public var host: String?
1313+ public var accessToken: String?
1414+ public var refreshToken: String?
1515+ public var atProtocoldelegate: CoreATProtocolDelegate?
1616+ public let routerDelegate = APRouterDelegate()
1717+1818+ private init() {}
1919+2020+// func setup(apiKey: String, apiSecret: String, userAgent: String) {
2121+// self.apiKey = apiKey
2222+// self.apiSecret = apiSecret
2323+// self.userAgent = userAgent
2424+// }
2525+}
2626+
+57
Sources/CoreATProtocol/CoreATProtocol.swift
···11+// The Swift Programming Language
22+// https://docs.swift.org/swift-book
33+44+// MARK: - Session
55+66+/// Represents an authenticated AT Protocol session
77+public struct Session: Sendable, Codable, Hashable {
88+ public let did: String
99+ public let handle: String
1010+ public let email: String?
1111+ public let accessJwt: String?
1212+ public let refreshJwt: String?
1313+1414+ public init(did: String, handle: String, email: String? = nil, accessJwt: String? = nil, refreshJwt: String? = nil) {
1515+ self.did = did
1616+ self.handle = handle
1717+ self.email = email
1818+ self.accessJwt = accessJwt
1919+ self.refreshJwt = refreshJwt
2020+ }
2121+}
2222+2323+// MARK: - Delegate
2424+2525+public protocol CoreATProtocolDelegate: AnyObject, Sendable {
2626+ /// Called when the session is updated (e.g., tokens refreshed)
2727+ func sessionUpdated(_ session: Session) async
2828+}
2929+3030+// Default implementation for optional method
3131+public extension CoreATProtocolDelegate {
3232+ func sessionUpdated(_ session: Session) async {}
3333+}
3434+3535+@APActor
3636+public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) {
3737+ APEnvironment.current.host = hostURL
3838+ APEnvironment.current.accessToken = accessJWT
3939+ APEnvironment.current.refreshToken = refreshJWT
4040+ APEnvironment.current.atProtocoldelegate = delegate
4141+}
4242+4343+@APActor
4444+public func setDelegate(_ delegate: CoreATProtocolDelegate) {
4545+ APEnvironment.current.atProtocoldelegate = delegate
4646+}
4747+4848+@APActor
4949+public func updateTokens(access: String?, refresh: String?) {
5050+ APEnvironment.current.accessToken = access
5151+ APEnvironment.current.refreshToken = refresh
5252+}
5353+5454+@APActor
5555+public func update(hostURL: String?) {
5656+ APEnvironment.current.host = hostURL
5757+}
+32
Sources/CoreATProtocol/Models/ATError.swift
···11+//
22+// ATError.swift
33+// CoreATProtocol
44+//
55+// Created by Thomas Rademaker on 10/8/25.
66+//
77+88+public enum AtError: Error {
99+ case message(ErrorMessage)
1010+ case network(NetworkError)
1111+}
1212+1313+public struct ErrorMessage: Codable, Sendable {
1414+ /// The error type as a string. Kept as String rather than AtErrorType
1515+ /// to handle unknown error types that the server may return.
1616+ public let error: String
1717+ public let message: String?
1818+1919+ public init(error: String, message: String?) {
2020+ self.error = error
2121+ self.message = message
2222+ }
2323+}
2424+2525+public enum AtErrorType: String, Codable, Sendable {
2626+ case authenticationRequired = "AuthenticationRequired"
2727+ case expiredToken = "ExpiredToken"
2828+ case invalidRequest = "InvalidRequest"
2929+ case methodNotImplemented = "MethodNotImplemented"
3030+ case rateLimitExceeded = "RateLimitExceeded"
3131+ case authMissing = "AuthMissing"
3232+}
+71
Sources/CoreATProtocol/Networking.swift
···11+//
22+// Networking.swift
33+// CoreATProtocol
44+//
55+// Created by Thomas Rademaker on 10/10/25.
66+//
77+88+import Foundation
99+1010+extension JSONDecoder {
1111+ public static var atDecoder: JSONDecoder {
1212+ let dateFormatter = DateFormatter()
1313+ dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX"
1414+ dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
1515+ dateFormatter.locale = Locale(identifier: "en_US")
1616+1717+ let decoder = JSONDecoder()
1818+ decoder.keyDecodingStrategy = .convertFromSnakeCase
1919+ decoder.dateDecodingStrategy = .formatted(dateFormatter)
2020+2121+ return decoder
2222+ }
2323+}
2424+2525+func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool {
2626+ guard lastFetched != 0 else { return true }
2727+ let currentTime = Date.now
2828+ let lastFetchTime = Date(timeIntervalSince1970: lastFetched)
2929+ guard let differenceInMinutes = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false }
3030+ return differenceInMinutes >= timeLimit
3131+}
3232+3333+@APActor
3434+public class APRouterDelegate: NetworkRouterDelegate {
3535+ private var shouldRefreshToken = false
3636+3737+ public func intercept(_ request: inout URLRequest) async {
3838+ if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken {
3939+ shouldRefreshToken = false
4040+ request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization")
4141+ } else if let accessToken = APEnvironment.current.accessToken {
4242+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
4343+ }
4444+ }
4545+4646+ public func shouldRetry(error: Error, attempts: Int) async throws -> Bool {
4747+ func getNewToken() async throws -> Bool {
4848+// shouldRefreshToken = true
4949+// let newSession = try await AtProtoLexicons().refresh(attempts: attempts + 1)
5050+// APEnvironment.current.accessToken = newSession.accessJwt
5151+// APEnvironment.current.refreshToken = newSession.refreshJwt
5252+// await delegate?.sessionUpdated(newSession)
5353+//
5454+// return true
5555+ false
5656+ }
5757+5858+ // TODO: verify this works!
5959+ if case .network(let networkError) = error as? AtError,
6060+ case .statusCode(let statusCode, _) = networkError,
6161+ let statusCode = statusCode?.rawValue, (400..<500).contains(statusCode),
6262+ attempts == 1 {
6363+ return try await getNewToken()
6464+ } else if case .message(let message) = error as? AtError,
6565+ message.error == AtErrorType.expiredToken.rawValue {
6666+ return try await getNewToken()
6767+ }
6868+6969+ return false
7070+ }
7171+}
···11+import Foundation
22+33+extension CharacterSet {
44+ /// Creates a CharacterSet from RFC 3986 allowed characters.
55+ ///
66+ /// RFC 3986 states that the following characters are "reserved" characters.
77+ ///
88+ /// - General Delimiters: ":", "#", "[", "]", "@", "?", "/"
99+ /// - Sub-Delimiters: "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "="
1010+ ///
1111+ /// In RFC 3986 - Section 3.4, it states that the "?" and "/" characters should not be escaped to allow
1212+ /// query strings to include a URL. Therefore, all "reserved" characters with the exception of "?" and "/"
1313+ /// should be percent-escaped in the query string.
1414+ static let apURLQueryAllowed: CharacterSet = {
1515+ let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4
1616+ let subDelimitersToEncode = "!$&'()*+,;="
1717+ let encodableDelimiters = CharacterSet(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)")
1818+1919+ return CharacterSet.urlQueryAllowed.subtracting(encodableDelimiters)
2020+ }()
2121+}
···11+import Foundation
22+33+public protocol EndpointType: Sendable {
44+ var baseURL: URL { get async }
55+ var path: String { get }
66+ var httpMethod: HTTPMethod { get }
77+ var task: HTTPTask { get async }
88+ var headers: HTTPHeaders? { get async }
99+}
···11+public enum HTTPMethod : String {
22+ case get = "GET"
33+ case post = "POST"
44+ case put = "PUT"
55+ case patch = "PATCH"
66+ case delete = "DELETE"
77+}
···11+import Testing
22+@testable import CoreATProtocol
33+44+@Test func example() async throws {
55+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
66+}
+134
Tests/CoreATProtocolTests/OAuthTests.swift
···11+import Foundation
22+import Testing
33+import OAuthenticator
44+@testable import CoreATProtocol
55+66+@Suite("Identity Resolution")
77+struct IdentityResolverTests {
88+99+ @Test("Resolve well-known handle via HTTPS")
1010+ func testResolveHandle() async throws {
1111+ let resolver = await IdentityResolver()
1212+1313+ // atproto.com is a stable test handle
1414+ let identity = try await resolver.resolve(handle: "atproto.com")
1515+1616+ print("DID: \(identity.did)")
1717+ print("PDS: \(identity.pdsEndpoint)")
1818+ print("Auth Server: \(identity.authorizationServer)")
1919+ print("Auth Server Host: \(identity.authServerHost)")
2020+2121+ #expect(identity.did.hasPrefix("did:"))
2222+ #expect(identity.pdsEndpoint.hasPrefix("https://"))
2323+ #expect(identity.authorizationServer.hasPrefix("https://"))
2424+ #expect(!identity.authServerHost.hasPrefix("https://"))
2525+ }
2626+2727+ @Test("Handle with @ prefix is cleaned")
2828+ func testHandleCleaning() async throws {
2929+ let resolver = await IdentityResolver()
3030+3131+ let identity = try await resolver.resolve(handle: "@atproto.com")
3232+3333+ #expect(identity.handle == "atproto.com")
3434+ }
3535+3636+ @Test("ServerMetadata loads from auth server host")
3737+ func testServerMetadataLoad() async throws {
3838+ let resolver = await IdentityResolver()
3939+ let identity = try await resolver.resolve(handle: "atproto.com")
4040+4141+ let provider: URLResponseProvider = { request in
4242+ try await URLSession.shared.data(for: request)
4343+ }
4444+4545+ // This should not throw - tests that authServerHost works with ServerMetadata.load
4646+ let serverConfig = try await ServerMetadata.load(
4747+ for: identity.authServerHost,
4848+ provider: provider
4949+ )
5050+5151+ print("Authorization endpoint: \(serverConfig.authorizationEndpoint)")
5252+ print("Token endpoint: \(serverConfig.tokenEndpoint)")
5353+5454+ #expect(serverConfig.authorizationEndpoint.hasPrefix("https://"))
5555+ #expect(serverConfig.tokenEndpoint.hasPrefix("https://"))
5656+ }
5757+5858+ @Test("ClientMetadata loads from URL")
5959+ func testClientMetadataLoad() async throws {
6060+ let provider: URLResponseProvider = { request in
6161+ try await URLSession.shared.data(for: request)
6262+ }
6363+6464+ // Use the real Plume client metadata
6565+ let clientConfig = try await ClientMetadata.load(
6666+ for: "https://sparrowtek.com/plume.json",
6767+ provider: provider
6868+ )
6969+7070+ print("Client ID: \(clientConfig.clientId)")
7171+ print("Redirect URIs: \(clientConfig.redirectURIs)")
7272+7373+ #expect(clientConfig.clientId == "https://sparrowtek.com/plume.json")
7474+ }
7575+}
7676+7777+@Suite("DPoP JWT")
7878+struct DPoPTests {
7979+8080+ @Test("JWT generation with JWTKit")
8181+ func testJWTGeneration() async throws {
8282+ // This tests that jwt-kit is properly integrated
8383+ // The actual JWT signing is tested via OAuthenticator integration
8484+8585+ let storage = ATProtoAuthStorage(
8686+ retrieveLogin: { nil },
8787+ storeLogin: { _ in },
8888+ retrievePrivateKey: { nil },
8989+ storePrivateKey: { _ in }
9090+ )
9191+9292+ let config = ATProtoOAuthConfig(
9393+ clientMetadataURL: "https://example.com/client-metadata.json",
9494+ redirectURI: "example://callback"
9595+ )
9696+9797+ let client = await ATProtoOAuth(config: config, storage: storage)
9898+9999+ // Verify key was generated
100100+ let keyPEM = await client.privateKeyPEM
101101+ #expect(!keyPEM.isEmpty)
102102+ #expect(keyPEM.contains("BEGIN PRIVATE KEY"))
103103+ }
104104+105105+ @Test("Private key can be exported and restored")
106106+ func testKeyPersistence() async throws {
107107+ let storage = ATProtoAuthStorage(
108108+ retrieveLogin: { nil },
109109+ storeLogin: { _ in },
110110+ retrievePrivateKey: { nil },
111111+ storePrivateKey: { _ in }
112112+ )
113113+114114+ let config = ATProtoOAuthConfig(
115115+ clientMetadataURL: "https://example.com/client-metadata.json",
116116+ redirectURI: "example://callback"
117117+ )
118118+119119+ // Create client and get its key
120120+ let client = await ATProtoOAuth(config: config, storage: storage)
121121+ let keyPEM = await client.privateKeyPEM
122122+123123+ // Create another client with the same key
124124+ let restoredClient = try await ATProtoOAuth(
125125+ config: config,
126126+ storage: storage,
127127+ privateKeyPEM: keyPEM
128128+ )
129129+ let restoredKeyPEM = await restoredClient.privateKeyPEM
130130+131131+ // Keys should match
132132+ #expect(keyPEM == restoredKeyPEM)
133133+ }
134134+}