# OAuth update plan for CoreATProtocol Goal: keep OAuthenticator as the OAuth 2.1 transport, but tighten the AT-Proto-specific layer above it. Borrow the few ideas AtprotoOAuth (MIT, germ-network) does well that genuinely improve correctness, testability, or readability. No library swap. Each step leaves the package building and the existing test suite green. ## Status (2026-04-29) **The plan is fully landed.** Steps 1–7 are all done. - Per-origin DPoP nonce caching, shared `URLOrigin.normalized` helper, extracted `TokenValidator`, `ATProtoIdentityResolver` injection seam, file split, and deprecated-symbol removal are all live. - CoreATProtocol has 71 tests (4 live-network ones opt-in via `CORE_ATPROTOCOL_LIVE_TESTS=1`); the deterministic suite runs in ~50ms. - bskyKit and EffemKit rebuild cleanly against the local checkout. Atprosphere + effem-iOS verified by source inspection — no consumer call sites broke. - Auto-memory updated: stale Plume entries dropped, four new memory files added covering the per-origin nonce design, consumer OAuth modes, the singleton testing constraint, and the live-tests env var. The only obvious next milestone is unrelated to this plan: **make `ATProtoSession` instance-based.** See "What didn't make this plan" near the bottom. See the "Notes from..." sections near the bottom for deviations from the original plan at each step. ## Inventory of what we have today Files (in `Sources/CoreATProtocol/`): - `OAuth/ATProtoOAuth.swift` — 1016 lines. Authorize / refresh / DPoP JWT generation / token persistence. Single class doing everything. - `OAuth/AuthProxy.swift` — 294 lines. `URLResponseProvider` interceptor that re-routes PAR + token requests through an auth proxy, with bypass on transport failure. - `OAuth/IdentityResolver.swift` — 366 lines. Handle ↔ DID ↔ PDS ↔ auth server resolution + bidirectional handle verification. - `Networking.swift` — `APRouterDelegate` that signs each authenticated XRPC request with a DPoP proof, watches for `use_dpop_nonce`, refreshes on 401/403, harvests clock skew. - `APEnvironment.swift` — `ATProtoSession.shared` singleton holding tokens + DPoP key + JWT key collection + nonce store + clock skew store. - `DPoPNonceStore.swift` — single-slot actor for the most recent server nonce. - `ClockSkewStore.swift` — observed offset between local and server clocks. - `TokenRefreshCoordinator.swift` — coalesces concurrent refresh attempts. - `Models/` — `Session`, `AtError`, etc. Consumers: - `bskyKit` re-exports `ATProtoOAuth` as `BskyOAuth` (via `@_exported import CoreATProtocol`). - `Atprosphere` calls `ATProtoOAuth(config:storage:)` with `clientAuthMethod: .privateKeyJWT` + `authProxyBaseURL`. - `effem` calls `ATProtoOAuth(config:storage:)` with `authProxyBaseURL` only (default `.none`). - `ATProtoCLI` does not use OAuth. Tests: 1012 lines across 9 files, including `OAuthTests.swift` (446 lines) covering proxy request encoding, key ID storage, DPoP key persistence, and identity resolution. ## What's worth borrowing from AtprotoOAuth 1. **Per-origin DPoP nonce cache.** AtprotoOAuth's `OAuth.DPoP.State.nonceCache` is keyed by origin (capped at 25 entries). RFC 9449 lets each server issue its own nonce; the auth server's nonce ≠ the PDS's nonce. Our single-slot store causes spurious `use_dpop_nonce` retries when the most recent nonce is from the wrong origin. 2. **Consolidated DPoP signer type.** AtprotoOAuth bundles `signingKey` + `nonceCache` + `decoder` into one value (`OAuth.DPoP.State`). We have these scattered across `ATProtoSession` (`dpopPrivateKey`, `dpopKeys`, `dpopNonceStore`) and re-thread them through `Networking.swift` and `ATProtoOAuth.swift`. A single `DPoPSigner` actor would hold the lot. 3. **`htu` canonicalization helper.** We strip query+fragment in two places (`Networking.swift` line 92-96 and `ATProtoOAuth.swift` line 561). One small free function. 4. **Pluggable resolver protocol.** Right now `IdentityResolver` is concrete. AtprotoOAuth has `Atproto.Resolver` as a protocol with a default `verifiedResolve` and a `FallbackResolver` that tries primary then secondary. Useful for tests (inject a fake resolver) and for adding Slingshot/community DoH fallback later. 5. **Token validator closure.** AtprotoOAuth pulls token validation (sub matches expected DID, issuer matches expected auth server, DPoP token type, scope contains "atproto") out of the loginProvider into a single throwing closure. Easier to unit test in isolation. We already do this work — just inline. What we should *not* borrow: - `AtprotoOAuthAgent`/`AuthPDSAgent` — couples to germ's XRPC abstraction. - `OAuth.SessionState.Archive` — our `Login`/`storage` callbacks already do the same job. - AtprotoTypes `Atproto.DID`/`Handle`/`DIDDocument` — we have our own types, no need to swap. - The `AsyncStream` save model — a callback closure (what we have) is simpler and equally correct. ## Plan Each step ends with `swift test` passing in `CoreATProtocol/`, then a build of `bskyKit`, Atprosphere, and effem against the local CoreATProtocol checkout. Treat the build of all three apps as the integration gate. ### Step 1 — Add `DPoPSigner` (the consolidated signer actor) — DONE **Why first:** the per-origin nonce fix lands here and is the highest-value correctness improvement. Doing it before the file split keeps later diffs small. Create `Sources/CoreATProtocol/OAuth/DPoPSigner.swift`: ```swift import Foundation import Crypto import JWTKit /// Owns the DPoP signing key, the per-origin nonce cache, and JWT generation. /// /// One `DPoPSigner` per logical session. Nonces are keyed by origin /// (`scheme://host[:port]`) per RFC 9449 — the auth server and the PDS have /// independent nonce streams, and using the wrong one causes a spurious /// `use_dpop_nonce` challenge on the next request. public actor DPoPSigner { public struct ProofParameters: Sendable { public let httpMethod: String public let url: URL /// Access token to bind via `ath`. Pass `nil` for unauthenticated /// requests (PAR, token exchange). public let accessToken: String? } private let privateKey: ES256PrivateKey private let keys: JWTKeyCollection private var noncesByOrigin: [String: String] = [:] private let nonceCacheLimit = 25 private let clockSkew: ClockSkewStore public init(privateKey: ES256PrivateKey, clockSkew: ClockSkewStore) async { self.privateKey = privateKey let keys = JWTKeyCollection() await keys.add(ecdsa: privateKey) self.keys = keys self.clockSkew = clockSkew } public convenience init(clockSkew: ClockSkewStore) async { await self.init(privateKey: ES256PrivateKey(), clockSkew: clockSkew) } public var pemRepresentation: String { privateKey.pemRepresentation } public func sign(_ params: ProofParameters) async throws -> String { let htu = Self.canonicalHTU(params.url) let origin = Self.origin(for: params.url) let nonce = origin.flatMap { noncesByOrigin[$0] } let issuedAt = await clockSkew.serverAdjustedNow() let ath = params.accessToken.map(Self.athClaim) var header = JWTHeader() header.typ = "dpop+jwt" header.alg = "ES256" if let p = privateKey.parameters { header.jwk = [ "kty": .string("EC"), "crv": .string("P-256"), "x": .string(p.x.base64URLEncoded()), "y": .string(p.y.base64URLEncoded()), ] } let payload = DPoPProofPayload( htm: params.httpMethod, htu: htu, iat: .init(value: issuedAt), jti: .init(value: UUID().uuidString), nonce: nonce, ath: ath ) return try await keys.sign(payload, header: header) } /// Records a nonce against the origin of `url`, evicting the oldest entry /// if the cache exceeds its limit. public func cacheNonce(_ nonce: String, from url: URL) { guard let origin = Self.origin(for: url) else { return } if noncesByOrigin.count >= nonceCacheLimit, noncesByOrigin[origin] == nil { // Drop one arbitrary entry. A true LRU is overkill — bounded growth is the goal. if let key = noncesByOrigin.keys.first { noncesByOrigin.removeValue(forKey: key) } } noncesByOrigin[origin] = nonce } public func clearNonces() { noncesByOrigin.removeAll() } // MARK: - Helpers static func canonicalHTU(_ url: URL) -> String { var components = URLComponents(url: url, resolvingAgainstBaseURL: false) components?.query = nil components?.fragment = nil return components?.url?.absoluteString ?? url.absoluteString } static func origin(for url: URL) -> String? { guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil } let port = url.port.map { ":\($0)" } ?? "" return "\(scheme)://\(host)\(port)" } static func athClaim(for accessToken: String) -> String { let hash = SHA256.hash(data: Data(accessToken.utf8)) return Data(hash).base64URLEncodedString() } } private struct DPoPProofPayload: JWTPayload { let htm: String let htu: String let iat: IssuedAtClaim let jti: IDClaim let nonce: String? let ath: String? func verify(using key: some JWTAlgorithm) throws {} } ``` Tests to add (Swift Testing): - `DPoPSignerTests.signEmitsDpopJwtType` — verify header `typ=dpop+jwt`, `alg=ES256`, JWK has `kty/crv/x/y`. - `DPoPSignerTests.htuStripsQueryAndFragment` — `https://x/y?z#a` → `https://x/y`. - `DPoPSignerTests.athPresentOnlyWhenAccessTokenProvided` — round-trip JWT, decode payload, check `ath`. - `DPoPSignerTests.nonceCachedPerOrigin` — cache nonces for two origins, verify each origin gets its own nonce in the next signed proof. - `DPoPSignerTests.nonceCacheBounded` — push 30 origins, assert ≤ 25 entries. - `DPoPSignerTests.iatRespectsClockSkew` — set `ClockSkewStore` offset to +600, sign, decode `iat`, assert ≈ now + 600. Don't wire it into `Networking.swift` or `ATProtoOAuth.swift` yet — that's Step 2. ### Step 2 — Replace ad-hoc DPoP signing in `Networking.swift` and `ATProtoOAuth.swift` — DONE `APRouterDelegate.intercept` currently reads `dpopPrivateKey`, `dpopKeys`, `dpopNonceStore` from `ATProtoSession.shared` and builds the proof inline (Networking.swift:55-129). Replace with a call to a `DPoPSigner` held on the session. Changes: - Add `var dpopSigner: DPoPSigner?` to `ATProtoSession`. - Update `setDPoPPrivateKey(pem:)` in `CoreATProtocol.swift` to instantiate `DPoPSigner` instead of populating `dpopPrivateKey` + `dpopKeys`. - Replace `APRouterDelegate.generateDPoPProof` body with `try await signer.sign(.init(httpMethod: method, url: url, accessToken: accessToken))`. - Replace `APRouterDelegate.didReceiveErrorResponse`'s `dpopNonceStore.update(headerNonce)` with `signer.cacheNonce(headerNonce, from: response.url ?? request.url)`. The response URL is the right origin to key on. - In `ATProtoOAuth.generateJWT` (the auth-server-side signer at line 528), build a `DPoPSigner.ProofParameters` from `params.requestEndpoint` + `params.httpMethod` + `nil` access token. Delete the local `stripQueryAndFragment` helper (line 561). - Keep `DPoPNonceStore` for now — it backs the deprecated `dpopNonceStore` accessor on the session. Mark it `@available(*, deprecated, message: "Use DPoPSigner.cacheNonce instead.")` and migrate callers in Step 6. - Keep the existing `dpopPrivateKey` / `dpopKeys` accessors on `ATProtoSession`, but mark them deprecated. They're public, so removing them is a major-version change. After this, `Networking.swift` shrinks by ~50 lines. The whole DPoP proof construction lives in one file. Tests to update: - `DPoPStoreTests.swift` continues to test `DPoPNonceStore` for now. The new `DPoPSignerTests` covers the new path. - Add an integration test in `OAuthTests.swift` that drives a fake `URLResponseProvider`, sees a `DPoP-Nonce` header, and confirms the next outbound proof carries the nonce. ### Step 3 — Centralize URL canonicalization + origin helpers — DONE The `htu` and origin logic now lives in `DPoPSigner` (steps 1–2). Two more places use it: - `ATProtoOAuth.normalizedOrigin(_:)` (line 901) — for issuer comparison. - `IdentityResolver.normalizedOrigin(from:)` (line 287) — for auth-server-vs-PDS check. Both are private and trivially identical. Lift them into a small file `Sources/CoreATProtocol/OAuth/URLOrigin.swift`: ```swift enum URLOrigin { static func normalized(_ url: URL) -> String? { guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil } let port = url.port.map { ":\($0)" } ?? "" return "\(scheme)://\(host)\(port)" } } ``` Update three call sites (`DPoPSigner.origin`, `ATProtoOAuth.normalizedOrigin`, `IdentityResolver.normalizedOrigin`) to call `URLOrigin.normalized`. Internal helper, no public API change. ### Step 4 — Extract the token validator closure — DONE `ATProtoOAuth.loginProvider` (line 759) inlines all the token-response validation: DPoP token type, `atproto` scope, issuer matches expected auth server, sub matches expected DID. Same checks duplicate in `refreshProvider` (line 834). Extract to a private helper: ```swift private struct TokenValidator: Sendable { let expectedAuthorizationServer: String let expectedSubjectDID: String? func validate( response: OAuthTokenResponse, actualIssuer: String ) throws { guard response.tokenType == "DPoP" else { throw AuthenticatorError.dpopTokenExpected(response.tokenType) } guard response.scopes.contains("atproto") else { throw ATProtoOAuthError.missingRequiredScope("atproto") } try ATProtoOAuth.validateIssuer(actualIssuer, matches: expectedAuthorizationServer) if let expectedSubjectDID, response.subject != expectedSubjectDID { throw ATProtoOAuthError.subjectMismatch( expected: expectedSubjectDID, actual: response.subject ) } } } ``` Both providers call `validator.validate(response:actualIssuer:)`. Worth ~30 lines saved and the validator becomes directly unit-testable. Tests: add `TokenValidatorTests` covering the four failure modes (wrong token type, missing scope, issuer mismatch, sub mismatch) and the success path. ### Step 5 — Make `IdentityResolver` a protocol — DONE Today `IdentityResolver` is a concrete struct. Tests have to hit live PDS endpoints (see `OAuthTests.swift` line 11-25 — calls `atproto.com`). That's fine for a smoke test, terrible for CI determinism. Introduce: ```swift public protocol ATProtoIdentityResolver: Sendable { func resolve(identifier: String) async throws -> IdentityResolver.ResolvedIdentity func isAuthorizationServer(_ authorizationServer: String, validFor pdsEndpoint: String) async throws -> Bool } extension IdentityResolver: ATProtoIdentityResolver {} ``` Add an injection point on `ATProtoOAuth.init`: ```swift public init( config: ATProtoOAuthConfig, storage: ATProtoAuthStorage, identityResolver: ATProtoIdentityResolver = IdentityResolver() ) async { ... } ``` Don't add a "fallback resolver" yet — YAGNI until you actually need a Slingshot/community DoH backup. Just open the seam. Tests: replace the live-network identity tests in `OAuthTests.swift` with a fake `ATProtoIdentityResolver` that returns canned `ResolvedIdentity` values. Keep one live integration test, but mark it with `@Test(.disabled(if: ...))` or a tag so it's opt-in. ### Step 6 — Optional: split `ATProtoOAuth.swift` along seams — DONE Only do this if Steps 1–5 still leave the file feeling unwieldy. The seams are clear: - `ATProtoOAuth.swift` (~400 lines) — `ATProtoOAuth` class, public config, public errors. - `OAuth/TokenHandling+ATProto.swift` (~250 lines) — `buildTokenHandling`, `authorizationURLProvider`, `loginProvider`, `refreshProvider` extracted as private extensions on `ATProtoOAuth`. - `OAuth/TokenModels.swift` (~150 lines) — `OAuthTokenRequest`, `OAuthRefreshTokenRequest`, `OAuthTokenResponse`. Don't change behavior in this step — pure file move + access-control tightening. Once the split lands, drop the deprecated `ATProtoSession.dpopPrivateKey` / `dpopKeys` / `dpopNonceStore` accessors (Step 2 deprecated them). Remove `DPoPNonceStore.swift`. Bump CoreATProtocol's tag — this is a SemVer-major change because the deprecated symbols are public. ### Step 7 — Update auto-memory — DONE After all of this lands, update `~/.claude/projects/-Users-rademaker-Developer-SparrowTek-AtProto/memory/MEMORY.md`: - Note that DPoP signing is consolidated in `OAuth/DPoPSigner.swift`. - Note the per-origin nonce cache (so future-Claude doesn't try to re-introduce a single-slot store). - Note that Atprosphere uses `clientAuthMethod: .privateKeyJWT`, effem uses `.none`, and the auth proxy path is exercised in production by both. - Drop the stale Plume entries — that directory no longer exists in this checkout. ## What we're explicitly NOT doing - **Not switching to AtprotoOAuth.** Their README says it's not ready, they don't support `private_key_jwt`, and adopting them means adopting AtprotoTypes/AtprotoClient too. - **Not removing `clientAuthMethod` or `AuthProxy.swift`.** Atprosphere's production OAuth depends on the confidential-client + auth-proxy path. - **Not changing the public API surface in Atprosphere/effem.** Steps 1–5 are additive or internal. Only Step 6's deprecation removal is breaking, and that's gated behind a SemVer bump. - **Not introducing new dependencies.** `swift-crypto` is already pulled in transitively via `jwt-kit`; everything else is Foundation. ## Risk + rollback | Step | Risk | Rollback | | --- | --- | --- | | 1 (add DPoPSigner) | Low — new file, not wired in. | Delete the file. | | 2 (wire in DPoPSigner) | Medium — touches every authenticated request. | Revert; deprecated accessors still work. | | 3 (URL canonicalization) | Low — pure refactor. | Revert. | | 4 (extract validator) | Low — pure refactor. | Revert. | | 5 (resolver protocol) | Low — additive. | Revert. | | 6 (file split + deprecation removal) | Medium — public API change. | SemVer-major bump means consumers opt in. | ## Order of work, in PR-size chunks 1. **PR 1**: Step 1 — `DPoPSigner` + tests. Standalone, no consumer changes. **DONE**, bundled with PR 2. 2. **PR 2**: Step 2 — wire `DPoPSigner` into `Networking.swift` + `ATProtoOAuth.swift`. Largest diff. Verify against Atprosphere + effem before merge. **DONE**. 3. **PR 3**: Steps 3 + 4 — URL canonicalization + token validator. Small refactor PR. **DONE**. 4. **PR 4**: Step 5 — resolver protocol + offline tests. **DONE**. 5. **PR 5** (SemVer-major): Step 6 — file split and deprecation removal. **DONE**. 6. **PR 6** (housekeeping): Step 7 — auto-memory refresh. **DONE**. Steps 1–5 are non-breaking. Step 6 is breaking and can wait until you have another reason to bump the major version. ## Notes from the Steps 1+2 implementation - **Renamed the new actor to `DPoPProofSigner`.** OAuthenticator already exports `public final class DPoPSigner` (its `JWTGenerator` typealias is the entry point we hand to `TokenHandling`), so the plan's name would have collided. The new file is `Sources/CoreATProtocol/OAuth/DPoPProofSigner.swift`; everywhere the plan says `DPoPSigner` for our new actor, the code says `DPoPProofSigner`. OAuthenticator's `DPoPSigner` keeps its name and role (per-flow nonce holder for `Authenticator`). - **Added an `explicitNonce` parameter to `ProofParameters`.** The auth-server flow runs through `OAuthenticator.DPoPSigner`, which already tracks the auth-server nonce for the exchange and passes it via `JWTParameters.nonce`. We forward that as `explicitNonce` so the call doesn't consult the per-origin cache (which is the XRPC layer's). The XRPC layer in `Networking.swift` calls `sign(_:)` without an `explicitNonce` and the cache is consulted normally. - **Did not deprecate `dpopPrivateKey` / `dpopKeys` / `dpopNonceStore` yet.** The plan calls for `@available(*, deprecated, ...)` on those public properties, but applying it now would require `@available`-aware suppression at the write sites in `setDPoPPrivateKey(pem:)`. Cleaner to defer until Step 6, which removes them outright. The new `dpopProofSigner` accessor is the production read path; the legacy fields are dual-written in `setDPoPPrivateKey(pem:)` purely for source compatibility with external readers. - **Dropped the integration test from Step 2.** The plan suggested an end-to-end test that drives `APRouterDelegate.didReceiveErrorResponse` then `intercept` and verifies the cached nonce flows through. Implementation revealed that any test touching `ATProtoSession.shared` races with other suites that call `await ATProtoSession.shared.reset()` (`NonceDetectionTests`, `RefreshLoginTests`, `DPoPTests`). Swift Testing's `.serialized` trait only orders within a suite, not across suites. Two paths are open here: (1) globally serialize all session-touching suites, or (2) make the session instance-based so tests can spin up isolated sessions. (2) is the long-term fix and is closer to what's contemplated in `APEnvironment.swift`'s "future major release will make it instance-based" comment. Until then, the per-origin caching is fully covered by the unit tests in `DPoPProofSignerTests`, and the wiring through `Networking.swift` is straightforward delegation (the kind of code production traffic exercises immediately). - **Verified consumers.** bskyKit and EffemKit were rebuilt against the local CoreATProtocol checkout via a temporary `.package(path:)` swap — both succeeded. Atprosphere and the effem iOS app are `.xcodeproj` consumers; their call sites were inspected and only touch preserved public APIs (`setDPoPPrivateKey(pem:)`, `ATProtoOAuth(config:storage:)`, `ATProtoOAuthConfig`, `ATProtoOAuthError`, `ATProtoSession.shared.host`, `ATProtoSession.shared.routerDelegate`). - **Updated test:** `nonceCacheBounded` was rewritten — the original draft asserted "≥ 1 of the 5 oldest origins was evicted," which was probabilistic since Swift dictionaries don't guarantee key ordering. The new assertion is the deterministic invariant: after 30 insertions into a 25-slot cache, exactly 25 entries survive. ## Notes from the Steps 3+4 implementation - **Deleted the `normalizedOrigin` wrappers entirely** rather than having them delegate. `DPoPProofSigner.origin(for:)`, `ATProtoOAuth.normalizedOrigin(_:)`, and `IdentityResolver.normalizedOrigin(from:)` are gone; their five call sites now invoke `URLOrigin.normalized` directly. Wrappers that only forward to a one-liner are noise. - **`TokenValidator` is in its own file** — `Sources/CoreATProtocol/OAuth/TokenValidator.swift`. The plan placed it as a private nested type in `ATProtoOAuth.swift`, but the file is already 970+ lines and the validator is unit-tested directly (`TokenValidatorTests`), so a separate file is cleaner. The struct is `internal` (not `private`) so tests reach it via `@testable import CoreATProtocol`. - **`OAuthTokenResponse` was bumped from `private` to `internal` (and `Sendable`).** Tests construct it via the synthesized memberwise init — much simpler than building JSON and round-tripping through `Codable`. `OAuthTokenRequest` and `OAuthRefreshTokenRequest` stay `private`; nothing outside `ATProtoOAuth.swift` needs them. - **Issuer validation stays inline in `loginProvider`, not in `TokenValidator`.** The plan implied both providers shared an issuer check, but they don't: the auth-callback flow validates the `iss` query parameter (which the validator never sees), and the refresh flow has no `iss` to validate against — the auth-server URL is fixed by the time refresh runs. The validator handles only the response-payload checks (token type, scope, sub) that genuinely duplicated. Net change: ~10 lines of duplicated guards collapsed to two `validator.validate(_:)` calls. - **Tests added:** `TokenValidatorTests` covers the success path, wrong token type, missing `atproto` scope, sub mismatch, sub passthrough when expected DID is nil, and a multi-scope success case. Six tests, all sub-millisecond. Total suite is now 72 tests. ## Notes from the Step 5 implementation - **Protocol shape matches the existing concrete API.** `ATProtoIdentityResolver` exposes only the two methods `ATProtoOAuth` actually calls — `resolve(identifier:)` and `isAuthorizationServer(_:validFor:)`. The concrete `IdentityResolver`'s extras (`resolve(handle:)`, `resolve(did:)`) stay off the protocol; tests that need them keep using the concrete type. - **Optional parameter, not an autoclosure default.** Plan's signature was `identityResolver: ATProtoIdentityResolver = IdentityResolver()`. Default expressions on a global-actor-isolated `init` get murky in Swift 6 (the expression is evaluated relative to the function's isolation, but `IdentityResolver()` is itself `@APActor`-isolated). Sidestepped with `identityResolver: (any ATProtoIdentityResolver)? = nil` and `self.identityResolver = identityResolver ?? IdentityResolver()` in the body. Same caller ergonomics, no isolation puzzle. - **`ResolvedIdentity` stays nested in `IdentityResolver`.** Lifting it to a top-level `ATProtoResolvedIdentity` would be cleaner but renames a public type. Deferred to Step 6 (which is already a SemVer-major bump). The protocol references `IdentityResolver.ResolvedIdentity` directly. - **Live tests are opt-in via env var, not removed.** Plan suggested `.disabled(if:)` or a tag. Used a single env-var gate: `CORE_ATPROTOCOL_LIVE_TESTS=1` runs the four live network tests (`testResolveHandle`, `testHandleCleaning`, `testServerMetadataLoad`, `testClientMetadataLoad`); without it they're skipped and the deterministic suite stays at ~80ms. The smoke-test value of those tests is preserved for local debugging without polluting CI. - **Three new offline tests in `IdentityResolverProtocolTests`.** A `StubIdentityResolver` round-trips canned values through both protocol methods, plus one test verifies `ATProtoOAuth.init(config:storage:identityResolver:)` accepts the injected resolver. End-to-end behavior with the fake can't be tested offline because `authenticate(...)` still goes to live network for client/server metadata — that's a future seam. - **No consumer changes.** The new `identityResolver:` parameter has a default, so all existing `await ATProtoOAuth(config: ..., storage: ...)` and `try await ATProtoOAuth(config: ..., storage: ..., privateKeyPEM: ...)` call sites compile unchanged. bskyKit, EffemKit, Atprosphere, and effem-iOS all good. ## Notes from the Steps 6+7 implementation - **Removed more than just the DPoP-deprecated surface.** Plan Step 6 listed `ATProtoSession.dpopPrivateKey` / `dpopKeys` / `dpopNonceStore` and the `DPoPNonceStore` type as the things to delete. While we were already breaking, also deleted the orphan `APEnvironment` typealias and the `ATProtoSession.current` accessor (both `@available(*, deprecated, renamed:)` shims from a previous rename). Confirmed by grep that no consumer references either symbol. - **`DPoPNonceStoreTests` deleted; the test file was renamed.** The old `Tests/CoreATProtocolTests/DPoPStoreTests.swift` only had `ClockSkewStoreTests` left after removing the nonce-store tests, so it became `ClockSkewStoreTests.swift`. Test count dropped from 75 → 71 (lost 4 tests for the deleted store; gained nothing). - **File split kept `DPoPRequestActor` in the main file.** Plan didn't specify where to put it; moving it to the extension file would have required bumping its access level. It's a small infra type used by `refreshLoginIfNeeded`, so it stays alongside its only caller in `ATProtoOAuth.swift`. - **`validateIssuer` bumped from `private` to `internal`.** Cross-file extension access for `private` doesn't work in Swift — the `loginProvider` (now in `ATProtoOAuth+TokenHandling.swift`) needs to call `Self.validateIssuer(...)`, and the source-of-truth method lives in the main file (also called from `authenticate` and `refreshLoginIfNeeded` there). `internal` keeps it module-scoped. - **Final layout:** `ATProtoOAuth.swift` (720 lines — public types + class + auth orchestration + `DPoPRequestActor` + `validateIssuer`), `ATProtoOAuth+TokenHandling.swift` (186 lines — `buildTokenHandling`, the three providers, `decodeTokenResponse`, and `URLSession.defaultProvider`), `TokenModels.swift` (73 lines — the three Codable structs). The plan's "~400 lines" estimate for `ATProtoOAuth.swift` was light; the public types + orchestration logic are unavoidable. - **Auto-memory restructured to match the rules in the system prompt.** The previous `MEMORY.md` had inline content (the auto-memory rules require it to be a flat index pointing at typed memory files). Dropped six stale Plume-app entries (Plume isn't in this checkout), kept the generic `feedback_review_struct_vs_class.md`, added four new files: `feedback_dpop_per_origin_nonce.md`, `project_consumer_oauth_modes.md`, `feedback_atproto_session_singleton.md`, `reference_live_tests_env_var.md`. - **Stale `.docc` reference noted but not fixed.** `effem/EffemKit/Sources/EffemKit/EffemKit.docc/Authentication.md` still references `APEnvironment.current.dpopPrivateKey` (an API that's been gone for a while now). It's documentation, not code, so the build is fine. Worth a follow-up cleanup in the effem repo when someone touches that doc file. ## What didn't make this plan The most obvious payoff still on the table is **making `ATProtoSession` instance-based** (already flagged in `APEnvironment.swift`'s docstring as "a future major release"): - The singleton is what blocked the integration test from Step 2 (cross-suite `reset()` calls clobber state). - A scoped session would make the routing layer testable end-to-end without env-var gates. - Consumers would need updates: `setup()`, `updateTokens()`, `setDPoPPrivateKey(pem:)`, `setDelegate()`, `setTokenRefreshHandler()`, `update(hostURL:)` would all become methods on a session instance, and `APRouterDelegate` would need to be initialized with a session reference rather than reading `ATProtoSession.shared` directly. - It's a bigger lift than any single step in this plan — touches every consumer and the entire networking layer. Other smaller follow-ups: - Lift `IdentityResolver.ResolvedIdentity` to a top-level `ATProtoResolvedIdentity` (renames a public nested type; pairs naturally with the next major bump). - The `effem` `EffemKit.docc` references stale `APEnvironment` symbols (see Steps 6+7 notes). - Add a Slingshot/community DoH fallback resolver — the `ATProtoIdentityResolver` seam exists for exactly this; not built yet because nobody needs it. - Consider extracting `authenticate` and `refreshLoginIfNeeded` into their own extension file. They're ~200 lines together; the main file would shrink to ~520. Pure organizational; only worth doing if `ATProtoOAuth.swift` starts feeling unwieldy again.