refactor(server)!: EF Core package split, no Server dependencies, and an AtProto builder for DI #174
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!174
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-136-packages-di"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #136.
Second half of #136: the Packages and DI sections. The first half (Auth, OAuth hosting, Blazor widgets) was #173.
Packages
ATProtoNet.Server.EntityFrameworkCoreis its own package again. Every EF Core store andDbContextmoved out ofATProtoNet.Serverwithgit mv, namespaceATProtoNet.Server.EntityFrameworkCoreunchanged: the session store (EfCoreAtProtoSessionStore<T>,AtProtoTokenDbContext), the JTI replay store (EfCoreJtiReplayStore<T>,JtiReplayDbContext), the space stores (EfCoreSpaceAuthorityStore<T>,EfCoreSimpleSpaceStore<T>,SpaceDbContext), and the repo-sync state store (EfCoreRepoSyncStateStore<T>,RepoSyncStateDbContext), with theirConfigure…Modelhelpers andAdd…methods. The project is inATProto.NET.slnx, packs (checked:dotnet packgives a nuspec that depends onATProtoNet.ServerandMicrosoft.EntityFrameworkCore.Relational10.0.10, with the framework reference and the README), and getsInternalsVisibleTofrom core (AtProtoSessionJson) and Server (SpacePaging). Its tests moved totests/ATProtoNet.Tests/Server/EntityFrameworkCore/; the EF InMemory and Sqlite versions in the test project still match the src EF version (10.0.10).RedisSpaceReplayStore, its extensions,StackExchange.Redis, its unit and integration tests,[RequiresRedisFact]andATPROTO_REDIS_URLinTestInfrastructure.cs.docs/spaces.mdhas a new "A replay store on Redis" section with the wholeSET key value NX EX ttlIJtiReplayStore(about 15 lines) and itsReplaceregistration; the service-auth docs (docs/xrpc-handlers.md) link to it.Microsoft.Extensions.Http.Resilienceis gone. The builder exposes theIHttpClientBuilder(IAtProtoBuilder.HttpClient) so apps add their own.docs/server.mdanddocs/aspire.mdsay the SDK must not be combined with a handler that retries non-idempotent POSTs or replays DPoP proofs. They also show how to remove the handler that Aspire service defaults add to every client, withRemoveAllResilienceHandlers()(experimental,EXTEXP0001) on the SDK's client and the OAuth login's client.ATProtoNet.Serverhas no NuGet dependencies.dotnet list src/ATProtoNet.Server packagereports "No packages were found"; the csproj has only the coreProjectReferenceandFrameworkReference Microsoft.AspNetCore.App.ATProtoNetATProtoNet.ServerATProtoNet.Server.EntityFrameworkCoreATProtoNet.BlazorATProtoNet.Aspire.HostingDI
services.AddAtProto()is fixed and returns anIAtProtoBuilder(Services,HttpClient). Before,AddHttpClient<AtProtoClient>()registered the type as a transient typed client, so theTryAddSingletonnever ran. I reproduced the 0.6 registration in a scratch app: every resolution was a new client forhttps://bsky.social, whateverInstanceUrlsaid. Now there is one singleton built fromIOptions<AtProtoClientOptions>, and a regression test covers it. The registration hangs off the builder:WithLifetime(ServiceLifetime)replacesAddAtProtoScoped.WithOAuth(o => …)(the hosted login from #173) replacesAddAtProtoAuthentication.WithClientFactory()replaces the threeAddAtProtoServeroverloads. It registersIAtProtoClientFactory, theISessionRefreshCoordinator, and a store default.WithSessionStore<T>(),WithSessionStore(factory),WithInMemorySessionStore(),WithFileSessionStore(o => …), andWithEfCoreSessionStore<TContext>()from the EF package. The chosen store replaces the default in either order.WithHealthCheck(name, failureStatus, tags).builder.AddAtProtoClient(section, configure, connectionName)returns the builder too. It bindsAtProtoClientOptionsfrom the section, still honoursDisableHealthChecks, and replacesAtProtoClientSettings. It follows the Aspire convention: code over configuration, and a connection string (ConnectionStrings:{connectionName}, asWithReference(pds)supplies it) over the section.AuthenticationBuilder(AddAuthentication().AddAtProtoServiceAuth(...)). It is an authentication scheme that uses neither the client nor itsHttpClient, and it already goes throughIOptionswithValidateOnStart, so a builder method would only wrap it.InMemorySessionStoreWarning(a hosted service) warns when the resolved store is the in-memory default andWithInMemorySessionStore()was not chosen explicitly. Nothing is written to%LocalAppData%unlessWithFileSessionStore()asks for it.IOptions<T>with validation andValidateOnStart, and binds from configuration. This coversAtProtoClientOptions,AtProtoOAuthServerOptions,FileSessionStoreOptions,IdentityResolverOptions,SpaceServerOptionsandPdsAdminOptions. One internal helper (AddValidatedOptions) registers the options, anIValidateOptionsthat reports the check's message,ValidateOnStart, and the validated instance for the services that take the concrete type. Identity and cache options reuse core's existingValidate(). The OAuth validator absorbs theClientKeysandServeClientMetadatachecks.AtProtoServiceAuthOptionsalready did all this.IOptions.TapWebhookOptionsbelongs to eachMapTapWebhookendpoint, not to DI.ServiceAuthVerifierOptionsis built internally from the scheme options.AtProtoServiceCollectionExtensions.HttpClientName("ATProtoNet") is used by the registered client, the factory's per-user clients (which used"AtProtoClient") and the Aspire client.IHttpClientFactory.WithOAuth()registersAtProtoOAuthExtensions.HttpClientName("ATProtoNet.OAuth") with the identity fetch policy (public addresses only, no redirects, no proxy),HttpClientTimeoutand the SDK'sUser-Agent, and hands the factory to theAtProtoOAuthServiceit constructs through an internal property; the public constructor is unchanged. It stays a separate name because its primary handler must be the SSRF-safe one.OAuthClientlazily, when a request first needs it for an OAuth session. So resolvingIAtProtoClientFactorybefore the server has started no longer builds the loopback client, and no longer throws. A test covers it.ServiceCollectionExtensionsis renamedAtProtoServiceCollectionExtensions.Breaking changes (migration)
<PackageReference Include="ATProtoNet.Server.EntityFrameworkCore" />. The namespace, types and tables are unchanged.services.AddAtProtoServer()→services.AddAtProto().WithClientFactory().WithFileSessionStore(). Without.WithFileSessionStore(), sessions are kept in memory and a warning is logged.services.AddAtProtoServer("/dir")→….WithClientFactory().WithFileSessionStore(o => o.Directory = "/dir").services.AddAtProtoServer<TStore>()→….WithClientFactory().WithSessionStore<TStore>().services.AddAtProtoAuthentication(o => …)→services.AddAtProto().WithOAuth(o => …).services.AddAtProtoScoped(o => …)→services.AddAtProto(o => …).WithLifetime(ServiceLifetime.Scoped).services.AddAtProto<TStore>(o => …)→services.AddAtProto(o => …).WithSessionStore<TStore>().services.AddAtProtoEfCoreSessionStore<TContext>()→….WithEfCoreSessionStore<TContext>().builder.AddAtProtoClient(configureSettings: s => …)→builder.AddAtProtoClient(configure: o => …).AtProtoClientSettingsis removed. The config keysInstanceUrl,AutoRefreshSessionandDisableHealthChecksstill work.DisableResilienceis gone, and no resilience handler is added.AddAtProtoClientreturnsIAtProtoBuilder, notIHostApplicationBuilder."AtProtoClient"move toIAtProtoBuilder.HttpClient("ATProtoNet").AddAtProtoIdentity(configure)andAddAtProtoSpaces(configure)now apply every call's configuration, in order. Before, the first call's options won.StartAsyncwith anOptionsValidationException.AddAtProtoPdsAdmin()used to throwInvalidOperationExceptionfrom the registration call; the messages still name the configuration key.RedisSpaceReplayStoreis removed: copy the snippet fromdocs/spaces.md#a-replay-store-on-redis.Verification
b4ae539had 4991/0/5; 7 Redis unit tests were removed, and the net addition is 46.--no-incremental): 0 warnings, 0 errors. The integration tests compile; they are gated and skip.tests/ATProtoNet.Tests/Server/AtProtoBuilderTests.cshas 40 tests, one or more for everyAdd*/With*:ClientMetadataincluded;ValidateOnStartfailures through a started host, for the client, OAuth (7 cases), file store, identity and spaces options;HttpClientcarries the requests;AddJetstream.AddAtProtoBlazorresolves one accessor per scope overWithClientFactory()(ValidateOnBuild).ValidateOnStart, and that the options-instance overload copies every property.WithEfCoreSessionStoreis tested against SQLite.IJtiReplayStore,RemoveAllResilienceHandlers,AddStandardResilienceHandler(o => o.Retry.DisableForUnsafeHttpMethods()), theConfigure<T>(section)lines,WithEfCoreSessionStore, andAddAtProtoClient(connectionName:). At runtime, withConfigureHttpClientDefaults(h => h.AddStandardResilienceHandler()),RemoveAllResilienceHandlers()strips the resilience handler fromATProtoNetandATProtoNet.OAuth, andotherkeeps it.samples/ServerIntegrationSamplewas run on the builder:/and/loginreturn 200./login?error=handle_resolution_failed./atproto/login?handle=bsky.appPARs at bsky.social withclient_id=http://localhost?redirect_uri=http://127.0.0.1:5010/atproto/callback…, through theATProtoNet.OAuthnamed client (itsHttpClientlogs appear).WithInMemorySessionStore()explicitly.For the owner
ATProtoNet.Server.EntityFrameworkCoreexists from 0.4.0, when it was published orphaned and relinked by hand. A later upload never re-evaluates the link, so check after the 0.7.0 release that it shows on the repository's Packages tab. If it does not, relink it withPOST /api/v1/packages/Grandiras/nuget/ATProtoNet.Server.EntityFrameworkCore/-/link/ATProto.NET.RepositoryUrlhandling is unchanged, and the packed nuspec carrieshttps://git.grandiras.net/Grandiras/ATProto.NET. If the package was deprecated or unlisted on nuget.org when it was merged in 0.5.0, undo that.CLAUDE.mdis stale (left for #138):AddAtProto,AddAtProtoServer), JWT auth handler".TokenStore/EntityFrameworkCore/" of Server, and "merged in from separate packages".src/projects" (now five).ATPROTO_REDIS_URL/[RequiresRedisFact]bullet.MapAtProtoOAuth()".Deferred / flagged
SpaceServerOptions.ServiceDid(Did) does not bind from configuration: the identity types have noTypeConverter. It is documented as set in code; aTypeConverterforDidwould be a core change.ISessionRefreshCoordinator; that is documented, not shipped.AddAtProtoEfCoreSessionStore,AddAtProto<TSessionStore>,AddAtProtoServer<TSessionStore>andAddAtProtoScopedas they were at the time. The new migration list covers them; I left them unedited. I did edit three #173 bullets that gave migration steps pointing at removed calls.Review fixes
Eight commits on top of
80f94f1. Each code fix has a regression test, and I reverted each fix to confirm its test then fails. Each fix updates the CHANGELOG in the same commit.79df5ec, docs).AddStandardResilienceHandler(o => o.Retry.DisableForUnsafeHttpMethods())is gone fromdocs/aspire.mdanddocs/server.md. Under service defaults it stacked a second handler."ATProtoNet"is shared with the per-user OAuth clients, so no retry policy is safe on it. A resent request carries a spent DPoP proof, and the defaults' 10 s attempt timeout cuts off large downloads.docs/error-handling.md#retry-pattern); the wrapper is now also told to retry only calls that are safe to repeat.docs/error-handling.mdno longer pairs the SDK with a retrying handler. The stale "resilience" promises indocs/index.mdanddocs/managed-pds.mdare fixed.cafd12f).WithHealthCheckreplaces a check with the same name. Tests coverAddAtProtoClient().WithHealthCheck()andAddAtProtoClient()twice, and both run/health.cafd12f). An unreachable PDS reports the registration'sFailureStatus(Degraded), notUnhealthy. This also fixes a bug that shipped in 0.6.6bb2377).PostConfigure:AddAtProto,WithOAuth,WithFileSessionStore, identity, spaces and PDS admin.AddAtProto(o => …)beforeAddAtProtoClient()orConfigure<T>(section)no longer loses to the configuration.1fe7512).User-Agent.IHttpClientFactory. OnlyWithOAuth()hands it over, through an internal property, because onlyWithOAuth()gives that name the SSRF policy.ActivatorUtilitiesfrom a container withoutWithOAuth()now keeps its own policy client. The test checksConnectCallbackandAllowAutoRedirect, and it failed before the fix.AtProtoOAuthServerOptions.HttpClientdoc no longer offers "a proxy". For an egress proxy it says to supply the client.a112d11).UserAgentandRateLimitfromAddAtProto(o => …).InstanceUrl,AutoRefreshSession,BackgroundRefresh,RefreshCoordinator) stay the factory's. This is documented onWithClientFactoryand indocs/server.md.MaxRetries = 0: 1 request instead of 4, carryingMyApp/1.0.ServiceDid(7e7fdf1).AddSpaceAuthorityvalidates it with the options at startup. Before, it threwInvalidOperationExceptionfrom the startup check.AddAtProtoSpaces()alone still starts without one. The code has always allowed that for verifier-only and repo-host services, so theSpaceServerOptionsremark ("always required") is corrected.79df5ec). The custom-store example takes anIDbContextFactory, and theWithSessionStoreXML docs say the store is a singleton that must not capture a scopedDbContext.9aa0de4,1fe7512).ConnectCallbackset andUseProxyoff, andAllowPrivateNetworkslifts the address check.AtProtoOAuthService, the client factory andAtProtoClientOptionsshare oneISessionRefreshCoordinator, whichever ofWithOAuth()andWithClientFactory()comes first.After the fixes: 5045 passed, 0 failed, 5 skipped;
--no-incrementalbuild 0 warnings, 0 errors.origin/mainis unchanged atf750468.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-27T02:37:01.460Z) — nothing to re-trigger.
@claude /review — the second half of #136. #173 was the first. The PR is large and mostly moves files, so the diff will be truncated. Please read these in full from the branch:
src/ATProtoNet.Server/Extensions/IAtProtoBuilder.cs,AtProtoServiceCollectionExtensions.cs,AtProtoBuilderExtensions.csandAtProtoOptionsRegistration.cs: lifetimes, TryAdd semantics, and whether the client really resolves from the configured options;Authentication/AtProtoOAuthExtensions.cs: the OAuth login on the builder, its named HttpClient, and the lazily resolvedOAuthClient;Aspire/AtProtoAspireExtensions.csandAdmin/PdsAdminExtensions.cs: configuration binding andValidateOnStart;TokenStore/InMemorySessionStoreWarning.cs;src/ATProtoNet.Server.EntityFrameworkCoreproject: csproj, namespaces kept, and packability.Focus on captive dependencies (a singleton holding a scoped service), a user's client leaking across requests under
WithLifetime, registrations that silently do nothing, options that validate too late, and anything in docs or samples that still names a removed call (AddAtProtoServer,AddAtProtoScoped,AddAtProtoAuthentication,AddAtProtoEfCoreSessionStore,AtProtoClientSettings, Redis).🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
This is the second half of the #136 refactor (packages + DI), and it holds together well on close inspection. I checked out the PR branch's tree (via
git show/git grepagainstorigin/issue-136-packages-di, since it wasn't locally checked out) to verify the files the diff only partially showed, in particularAtProtoServiceCollectionExtensions.AddAtProto,IAtProtoBuilder/AtProtoBuilder,InMemorySessionStoreWarning/InMemorySessionStoreChoice,AtProtoClientFactory, and thePdsAdminOptions/PdsAdminExtensionsvalidation path.Things I specifically verified as correct:
WithClientFactory()onlyTryAddSingletons the in-memory default, while every explicitWith*SessionStorecall doesRemoveAll<IAtProtoSessionStore>()+RemoveAll<InMemorySessionStoreChoice>()before adding its own descriptor, so the explicit choice wins regardless of call order, and the in-memory startup warning only fires when no explicit choice marker was registered.AddAtProtoClient(Aspire). The section bind, the connection-string override, and theconfigurecallback are three separateConfigure<AtProtoClientOptions>registrations applied in that registration order, so "code wins over connection string wins over section" holds as documented.DisableHealthChecksis deliberately read straight off the rawIConfigurationSectionrather than bound ontoAtProtoClientOptions— a little asymmetric withInstanceUrl's precedence chain, but it's explicitly documented that way ("read byAddAtProtoClientonly"), so not a defect.AtProtoOAuthService's new optionalIHttpClientFactoryparameter is appended last, so existing positional/DI construction stays source- and binary-compatible;WithOAuth()correctly wires the namedATProtoNet.OAuthclient with the identity fetch policy and timeout, andAtProtoOAuthExtensions.ValidateOptionsis a reasonable, well-scoped set of checks (route paths, scopes, timeouts, confidential-client key/metadata pairing).AtProtoClientFactory's lazyOAuthClientresolution (Func<OAuthClient?>overload, with the DI registration closing over the singleton-timeIServiceProvider) genuinely defers resolution to first use, matching the loopback-callback fix described in the PR.PdsAdminOptionsbinding via.Bind(section)despiterequiredmembers is safe: the configuration binder doesn't enforcerequiredat bind time (that's compile-time only), so missing values simply stay default and are caught by the newValidateOptions/OptionsValidationExceptionpath at host startup, consistent with the documented breaking change (InvalidOperationExceptionat registration →OptionsValidationExceptionat startup).InternalsVisibleTowiring for the newATProtoNet.Server.EntityFrameworkCoreproject (from core, forAtProtoSessionJson, and from Server, forSpacePaging) is present as described.git mv) for the EF Core stores preserve namespaces and only touch doc-comment cross-references (AddAtProtoEfCoreSessionStore→WithEfCoreSessionStore), which is exactly the kind of change that should accompany a namespace-preserving package split.I didn't find any correctness, security, or omission issues worth blocking on. The CHANGELOG updates are thorough and the breaking-change migration table matches the code changes I could verify. Given the scale of this diff, I leaned on the PR's own extensive verification section (5030 unit tests, full build, doc-snippet compilation, manual sample run) rather than re-deriving all of it myself, but everything I spot-checked lines up with what's claimed.
⏱ 4m 5s · 38 turns · tokens: 113.1k in / 20.1k out (+3170.3k cache-read)
📜 Full session transcript
@claude /review, please re-review. Seven new commits (cafd12f..79df5ec) on top of the approved
80f94f1fix an independent adversarial review; see "Review fixes" in the description. Please read those commits in full (git diff 80f94f1..79df5ec):PostConfigure, so code beats bound configuration whatever the call order;WithOAuthgives the login its named client (the public constructor no longer takesIHttpClientFactory, so a hand-built service keeps the SSRF-safe owned client), and that client sends the SDK's User-Agent;UserAgentandRateLimitfromAddAtProto;ServiceDidis validated when a space authority is registered;DbContext.🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-27T03:07:10.725Z) — nothing to re-trigger.
✅ Verdict: approve — no blocking issues found.
This is a re-review after my prior APPROVE on this PR's earlier head (
80f94f1). Six "review fixes" commits landed since then (cafd12f,6bb2377,1fe7512,a112d11,7e7fdf1,9aa0de4) plus a docs-only commit (79df5ec), taking the head to79df5ec. I read each fix commit's diff directly against the branch (origin/issue-136-packages-di) rather than relying on the PR description, and all six hold up:cafd12f):WithHealthChecknow removes any existingHealthCheckRegistrationof the same name fromHealthCheckServiceOptionsbefore adding its own, viaConfigure<HealthCheckServiceOptions>, soAddAtProtoClient()called twice (or followed byWithHealthCheck()) no longer throws in the health check service.AtProtoPdsHealthChecknow returnscontext.Registration?.FailureStatus ?? HealthStatus.Unhealthyinstead of hardcodedUnhealthy. Both have direct tests.6bb2377): the sharedAddValidatedOptionshelper switched fromoptions.Configure(configure)tooptions.PostConfigure(configure). Since the options pipeline always runs everyConfigurebefore everyPostConfigureregardless of registration order, this genuinely makes the callback win whichever orderAddAtProto(o=>…),AddAtProtoClient()/Configure<T>(section)andAddAtProtoPdsAdmin()are called in — verified against the added order-flip tests (AddAtProto_ThenAddAtProtoClient_CodeWinsOverConfiguration, the PDS admin equivalent). Because the fix lives in the one shared helper, it correctly propagates to every consumer (WithOAuth,WithFileSessionStore, identity, spaces, PDS admin,AddAtProtoitself) without each call site needing its own change.1fe7512):AtProtoOAuthService's public constructor no longer acceptsIHttpClientFactory; the SSRF-safe named-client factory is now an internalinit-onlyPolicyHttpClientFactoryproperty set only byWithOAuth(). This closes the exact gap the description flags: a service built viaActivatorUtilitiesfrom a container that merely hasAddHttpClient()(noWithOAuth()) can no longer accidentally pick up an unconfigured"ATProtoNet.OAuth"client that follows redirects and reaches private addresses. The named client also now sends the SDK'sUser-Agent. New tests cover both the isolation and the header.UserAgent/RateLimit(a112d11):AtProtoClientFactorynow takes an optionalAtProtoClientOptionsand copies onlyUserAgent/RateLimitonto each per-request client's options, leaving session-specific fields (InstanceUrl,AutoRefreshSession, etc.) alone as documented.WithClientFactory()wiressp.GetService<IOptions<AtProtoClientOptions>>()?.Valuethrough.ServiceDidrequired only for an authority (7e7fdf1): the check moved intoAddSpaceAuthorityas anAddOptions<SpaceServerOptions>().Validate(...).ValidateOnStart(), scoped to that call only, soAddAtProtoSpaces()alone (verifier/repo-host-only) still starts without aServiceDid, matching the stated intent.9aa0de4,79df5ec): the former adds a two-way (loginFirst/not) test provingWithOAuth()andWithClientFactory()end up sharing oneISessionRefreshCoordinatorinstance; the latter only touches docs plus one XML-doc addition, consistent with "docs" scope.I did not re-run the build/test suite in this pass (git network operations required approval I didn't have in this session), but the diffs are small, self-contained, each paired with a targeted regression test as the PR claims, and consistent with the fixes described in the PR body. Nothing here reintroduces or overlaps with the issues from my previous pass. No new findings.
⏱ 2m 50s · 24 turns · tokens: 115.4k in / 8.7k out (+2798.8k cache-read)
📜 Full session transcript