refactor(server)!: OAuth hosting in ATProtoNet.Server, refresh coordination, working Blazor widgets #173

Merged
Grandiras merged 13 commits from issue-136-auth-hosting into main 2026-09-26 20:57:09 +00:00
Owner

Part of #136 (first half: auth, OAuth hosting and Blazor).

Covers the issue's Auth and Blazor sections, the two #167 review follow-ups, the confidential-client hosting deferred from #132, and the claim-type constants deferred from #125. The Packages and DI sections follow in the second PR; registration code keeps its current style so that PR can reshape it.

Auth

  • OAuth cookie login moves to ATProtoNet.Server (ATProtoNet.Server.Authentication): AtProtoOAuthService, AtProtoOAuthServerOptions, AddAtProtoAuthentication(), MapAtProtoOAuth(), the login binding. Its tests moved with it (tests/.../Server/Authentication/); the #167 security properties (login-CSRF binding cookie, local-only return URLs, per-requester pending-login bound, issuer verification) are unchanged and still covered.
  • Bearer-JWT AtProtoAuthenticationHandler deleted (with AtProtoAuthenticationOptions, AddAuthentication().AddAtProto()); service auth replaces it.
  • IOAuthClientProvider deleted. AtProtoOAuthService.Client is built from the options alone (no HttpContext) and registered as the OAuthClient singleton; AtProtoClientFactory takes an optional OAuthClient. The loopback client's callback comes from BaseUrl or the server's plain-HTTP loopback address (IServer), so it is identical after a restart and stored sessions refresh.
  • Per-DID refresh coordinator. Core gets ISessionRefreshCoordinator + InProcessSessionRefreshCoordinator and AtProtoClientOptions.RefreshCoordinator; SessionManager refreshes under the account's lock and re-reads the store after taking it: another client's refresh is adopted (new DPoP key loaded if it is a new sign-in), a session the store no longer holds ends (XrpcAuthenticationException, InvalidToken) without spending its token, otherwise it exchanges and persists before releasing. AddAtProtoServer() registers it; the factory's clients, the login's store write and logout all use it.
  • Factory caches each account's imported DPoP key (non-owning DPoPProofGenerator views, so disposing a client never breaks another; LRU-bounded at 1,024 accounts) and one logger.
  • FileAtProtoSessionStore: lock-free reads (FileShare.ReadWrite | Delete, atomic rename on write), per-account write locks (KeyedLock<T>, entries live only while held); a corrupt file is deleted only under the write lock after a re-read.
  • AtProtoClaimTypes (Did, Handle, HandleVerified, PdsUrl, AuthMethod, LexiconMethod, Audience) absorbs the three AtProtoServiceAuthDefaults.*ClaimType constants.
  • Error redirects carry a code: OAuthException.Error, the AS's error if it is a plain token (its error_description is dropped), else login_failed (logged). LoginForm maps codes to messages and never displays the code.
  • Typed callback result: CompleteCallbackAsync returns AtProtoOAuthCallbackResult (Did, RedirectUrl, IsRelay); IsIssuedRelayUrl is gone.
  • ServeClientMetadata: MapAtProtoOAuth() serves ClientMetadata at its client_id path and, with a jwks_uri, the public halves of ClientKeys there (fails at startup if misconfigured).
  • Confidential clients in the hosted login: AtProtoOAuthServerOptions.ClientKeys → OAuthOptions.ClientKeys (private_key_jwt).
  • #167 follow-ups: ResolveFromServerUrlAsync passes the resolved protected-resource metadata through (regression test with a clock that defeats the cache: 2 fetches, was 3); an unredeemed loopback relay entry is revoked when it expires (per-entry timer) or is evicted (new cap of 256).

Blazor

  • FeedView, PostCard, ProfileCard, ComposePost use the scoped AtProtoUserClientAccessor (AddAtProtoBlazor()), which creates the client via IAtProtoClientFactory from the auth state, shares it per scope, replaces it when the user changes. They previously injected an unregistered AtProtoClient.
  • FeedSource is a record type (Timeline, Author, Feed, List); FeedView reloads only when source/page size change and renders duplicate posts. PostCard likes/reposts/undoes itself without callbacks; ProfileCard loads by Actor; ComposePost counts graphemes and blocks >300. Error text is generic, exceptions are logged.
  • Opt-in stylesheet _content/ATProtoNet.Blazor/atproto-components.css; every class documented in docs/blazor.md.
  • <SupportedPlatform Include="browser" /> removed; core's stale InternalsVisibleTo ATProtoNet.Blazor removed.
  • Reflection in tests replaced by internal accessors (OAuthClient.HttpClient/ClientId, AtProtoClient.HttpClient, relay internals); the one left reads the BCL's HttpMessageInvoker._handler.
  • samples/ServerIntegrationSample: AddAtProtoBlazor(), profile page on ProfileCard, timeline page on ComposePost + FeedView, claim constants, the stylesheet.

Breaking changes (migration)

  • OAuth hosting moved: reference ATProtoNet.Server (Blazor still brings it) and replace using ATProtoNet.Blazor; / using ATProtoNet.Blazor.Authentication; with using ATProtoNet.Server.Authentication;. Registration calls unchanged.
  • Bearer handler removed: use AddAuthentication().AddAtProtoServiceAuth(o => o.Audiences.Add(...)) + [RequireServiceAuth].
  • IOAuthClientProvider removed: register your OAuthClient as a singleton; TryGetClient() → Client. AtProtoClientFactory ctor: (store, httpClientFactory, loggerFactory, OAuthClient? = null, ISessionRefreshCoordinator? = null).
  • CompleteCallbackAsync returns AtProtoOAuthCallbackResult → redirect to result.RedirectUrl; AtProtoOAuthService ctor gains optional IServer, ISessionRefreshCoordinator.
  • AtProtoServiceAuthDefaults.DidClaimType/LexiconMethodClaimType/AudienceClaimType → AtProtoClaimTypes.Did/LexiconMethod/Audience.
  • Loopback client without BaseUrl/ClientMetadata needs a plain-HTTP loopback binding (clear InvalidOperationException otherwise).
  • Widgets: call AddAtProtoBlazor(); FeedSource="author:x" → FeedSource="@FeedSource.Author(AtIdentifier.Parse("x"))"; ProfileCard.Profile no longer [EditorRequired].
  • Login error redirects carry a code, not a message (custom login pages read error as a code).

Verification

  • Unit tests before review: 4593 passed, 0 failed, 5 skipped (baseline 4518/0/5; after rebasing onto #170/#171 and the review fixes: 4863/0/5). Full solution build (--no-incremental): 0 warnings, 0 errors.
  • Real race: ConcurrentRequests_WithoutACoordinator_SpendTheRefreshTokenTwice (control: two token requests, one invalid_grant) vs …WithACoordinator_SpendTheRefreshTokenOnce (the first exchange is held until the second client is waiting on the lock; one exchange, both calls use at-2); plus 16 concurrent clients, and 8 concurrent requests through the factory. Stable over repeated runs.
  • New endpoint tests on TestServer (error codes, no message leak, metadata/JWKS served with public keys only, DI wiring), relay revoke on expiry/eviction with a manual clock, loopback address mapping, logout waiting for an in-progress refresh, file-store readers never seeing a missing/partial file under concurrent rewrites.
  • Widgets: bUnit 2.11.3 (MIT, targets net10.0 / ASP.NET Core 10.0.10, no central package management in the repo, no restore warnings), 30+ tests driving each widget against the real client factory and a stub PDS.
  • Sample run: /, /login, CSS asset 200; bad handle → /login?error=invalid_handle; AS error → /login?error=access_denied; a real /atproto/login?handle=bsky.app PARs at bsky.social with client_id=http://localhost?redirect_uri=http://127.0.0.1:5077/atproto/callback….
  • Benchmarks (Release, scratch console, this machine):
    • CreateClientForUserAsync (OAuth session, in-memory store): ~27 µs → ~4 µs per call, 8,656 → 4,536 B allocated.
    • FileAtProtoSessionStore, 64 concurrent callers: reads ~50k → ~290k ops/s (≈5.5×); 10 % writes ~36k → ~280k ops/s; single caller unchanged (~45–55k).

Review fixes

Seven commits on top of c1a6f4a, each with a regression test that fails without it (checked by reverting the fix) and its CHANGELOG change:

  • Sign-out no longer brings a session back (231f3b2). Four client paths change the store: sign-out, ForgetAsync on expiry, InstallAsync(persist) and UpdateAccountAsync. All four now take the account's lease after _transition, as refresh does. If a sign-out lands during another client's refresh, it waits for the refresh to finish, removes the stored result and revokes the token that refresh produced (rt-2), not its own spent copy. UpdateAccountAsync writes only while the store still holds its tokens. Tests: sign-out during a held refresh leaves the store empty and revokes rt-2; a sign-in during a held refresh keeps the new session.
  • External-embed links accept only http/https (4966526). Any other scheme (javascript:, data:, …) renders as a div with no link. The widgets have no other author-controlled href.
  • The stored login state is re-checked at the callback (86bfd91). OAuthLoginState.TryParse drops a return URL that is not local. It refuses an origin that is not a bare http(s) origin, which ends in login_not_bound. New test: a login started on a public host with a loopback callback is not relayed. The relay test now also checks that the missing-binding case revokes the session.
  • Only the OAuth login's user reaches its stored session (3734205). The factory, the accessor and /atproto/logout read the DID only from an identity the login issued (authentication type ATProto) or from one carrying auth_method=oauth. Service-auth identities are ignored, including on principals that also carry the cookie identity. Custom ClaimsFactory principals keep working because they still get the ATProto identity type. Listed as a breaking change: principals built by hand need auth_method=oauth.
  • Cached DPoP keys are dropped when a session ends (79a2770). This happens when a factory client's session is removed or expires, on the hosted logout, and when a request finds the store empty. Only a key matching the ended session is dropped, and it is disposed.
  • Cache headers and README (24cdeaf). The served metadata and JWKS carry Cache-Control: public, max-age=300. The README's hosting bullet no longer mentions the removed JWT handler.
  • Widgets retry, and the accessor re-checks the store (e400b87). FeedView and ProfileCard mark a load done only after it succeeds. They take the cascading AuthenticationState, so a sign-in, a sign-out or a re-render after a failure triggers a reload. AtProtoUserClientAccessor gets IAtProtoSessionStore in its constructor and reads the store on every call, so a session signed out elsewhere stops being handed out.

After review: 4863 passed, 0 failed, 5 skipped; 0 warnings, 0 errors.

Deferred / flagged

  • A relay still waiting at shutdown is dropped, not revoked (the container disposes the OAuthClient first); it lasts at most two minutes.
  • Resolving IAtProtoClientFactory before the server has started, with the dev loopback client and no BaseUrl, throws (the server's addresses are not known yet); production (ClientMetadata) is unaffected. The second half's DI/options work may want to revisit.
  • The in-process coordinator covers one process; multi-instance deployments need a distributed ISessionRefreshCoordinator (documented, not shipped).
  • CLAUDE.md still calls the Server package's auth a "JWT auth handler" and puts the OAuth endpoints in Blazor; left for the owner to edit.
  • Parallel-PR file touched: src/ATProtoNet.Server/Spaces/ISpaceCallerResolver.cs (#133) — one doc cref pointed at the deleted handler.

🤖 Generated with Claude Code

Part of #136 (first half: auth, OAuth hosting and Blazor). Covers the issue's **Auth** and **Blazor** sections, the two #167 review follow-ups, the confidential-client hosting deferred from #132, and the claim-type constants deferred from #125. The **Packages** and **DI** sections follow in the second PR; registration code keeps its current style so that PR can reshape it. ## Auth - **OAuth cookie login moves to `ATProtoNet.Server`** (`ATProtoNet.Server.Authentication`): `AtProtoOAuthService`, `AtProtoOAuthServerOptions`, `AddAtProtoAuthentication()`, `MapAtProtoOAuth()`, the login binding. Its tests moved with it (`tests/.../Server/Authentication/`); the #167 security properties (login-CSRF binding cookie, local-only return URLs, per-requester pending-login bound, issuer verification) are unchanged and still covered. - **Bearer-JWT `AtProtoAuthenticationHandler` deleted** (with `AtProtoAuthenticationOptions`, `AddAuthentication().AddAtProto()`); service auth replaces it. - **`IOAuthClientProvider` deleted.** `AtProtoOAuthService.Client` is built from the options alone (no `HttpContext`) and registered as the `OAuthClient` singleton; `AtProtoClientFactory` takes an optional `OAuthClient`. The loopback client's callback comes from `BaseUrl` or the server's plain-HTTP loopback address (`IServer`), so it is identical after a restart and stored sessions refresh. - **Per-DID refresh coordinator.** Core gets `ISessionRefreshCoordinator` + `InProcessSessionRefreshCoordinator` and `AtProtoClientOptions.RefreshCoordinator`; `SessionManager` refreshes under the account's lock and re-reads the store after taking it: another client's refresh is adopted (new DPoP key loaded if it is a new sign-in), a session the store no longer holds ends (`XrpcAuthenticationException`, `InvalidToken`) without spending its token, otherwise it exchanges and persists before releasing. `AddAtProtoServer()` registers it; the factory's clients, the login's store write and logout all use it. - **Factory caches each account's imported DPoP key** (non-owning `DPoPProofGenerator` views, so disposing a client never breaks another; LRU-bounded at 1,024 accounts) and one logger. - **`FileAtProtoSessionStore`**: lock-free reads (`FileShare.ReadWrite | Delete`, atomic rename on write), per-account write locks (`KeyedLock<T>`, entries live only while held); a corrupt file is deleted only under the write lock after a re-read. - **`AtProtoClaimTypes`** (`Did`, `Handle`, `HandleVerified`, `PdsUrl`, `AuthMethod`, `LexiconMethod`, `Audience`) absorbs the three `AtProtoServiceAuthDefaults.*ClaimType` constants. - **Error redirects carry a code**: `OAuthException.Error`, the AS's `error` if it is a plain token (its `error_description` is dropped), else `login_failed` (logged). `LoginForm` maps codes to messages and never displays the code. - **Typed callback result**: `CompleteCallbackAsync` returns `AtProtoOAuthCallbackResult` (`Did`, `RedirectUrl`, `IsRelay`); `IsIssuedRelayUrl` is gone. - **`ServeClientMetadata`**: `MapAtProtoOAuth()` serves `ClientMetadata` at its `client_id` path and, with a `jwks_uri`, the public halves of `ClientKeys` there (fails at startup if misconfigured). - **Confidential clients in the hosted login**: `AtProtoOAuthServerOptions.ClientKeys` → `OAuthOptions.ClientKeys` (`private_key_jwt`). - **#167 follow-ups**: `ResolveFromServerUrlAsync` passes the resolved protected-resource metadata through (regression test with a clock that defeats the cache: 2 fetches, was 3); an unredeemed loopback relay entry is revoked when it expires (per-entry timer) or is evicted (new cap of 256). ## Blazor - `FeedView`, `PostCard`, `ProfileCard`, `ComposePost` use the scoped `AtProtoUserClientAccessor` (`AddAtProtoBlazor()`), which creates the client via `IAtProtoClientFactory` from the auth state, shares it per scope, replaces it when the user changes. They previously injected an unregistered `AtProtoClient`. - `FeedSource` is a record type (`Timeline`, `Author`, `Feed`, `List`); `FeedView` reloads only when source/page size change and renders duplicate posts. `PostCard` likes/reposts/undoes itself without callbacks; `ProfileCard` loads by `Actor`; `ComposePost` counts graphemes and blocks >300. Error text is generic, exceptions are logged. - Opt-in stylesheet `_content/ATProtoNet.Blazor/atproto-components.css`; every class documented in `docs/blazor.md`. - `<SupportedPlatform Include="browser" />` removed; core's stale `InternalsVisibleTo ATProtoNet.Blazor` removed. - Reflection in tests replaced by internal accessors (`OAuthClient.HttpClient`/`ClientId`, `AtProtoClient.HttpClient`, relay internals); the one left reads the BCL's `HttpMessageInvoker._handler`. - `samples/ServerIntegrationSample`: `AddAtProtoBlazor()`, profile page on `ProfileCard`, timeline page on `ComposePost` + `FeedView`, claim constants, the stylesheet. ## Breaking changes (migration) - OAuth hosting moved: reference `ATProtoNet.Server` (Blazor still brings it) and replace `using ATProtoNet.Blazor;` / `using ATProtoNet.Blazor.Authentication;` with `using ATProtoNet.Server.Authentication;`. Registration calls unchanged. - Bearer handler removed: use `AddAuthentication().AddAtProtoServiceAuth(o => o.Audiences.Add(...))` + `[RequireServiceAuth]`. - `IOAuthClientProvider` removed: register your `OAuthClient` as a singleton; `TryGetClient()` → `Client`. `AtProtoClientFactory` ctor: `(store, httpClientFactory, loggerFactory, OAuthClient? = null, ISessionRefreshCoordinator? = null)`. - `CompleteCallbackAsync` returns `AtProtoOAuthCallbackResult` → redirect to `result.RedirectUrl`; `AtProtoOAuthService` ctor gains optional `IServer`, `ISessionRefreshCoordinator`. - `AtProtoServiceAuthDefaults.DidClaimType`/`LexiconMethodClaimType`/`AudienceClaimType` → `AtProtoClaimTypes.Did`/`LexiconMethod`/`Audience`. - Loopback client without `BaseUrl`/`ClientMetadata` needs a plain-HTTP loopback binding (clear `InvalidOperationException` otherwise). - Widgets: call `AddAtProtoBlazor()`; `FeedSource="author:x"` → `FeedSource="@FeedSource.Author(AtIdentifier.Parse("x"))"`; `ProfileCard.Profile` no longer `[EditorRequired]`. - Login error redirects carry a code, not a message (custom login pages read `error` as a code). ## Verification - Unit tests before review: **4593 passed, 0 failed, 5 skipped** (baseline 4518/0/5; after rebasing onto #170/#171 and the review fixes: 4863/0/5). Full solution build (`--no-incremental`): **0 warnings, 0 errors**. - **Real race**: `ConcurrentRequests_WithoutACoordinator_SpendTheRefreshTokenTwice` (control: two token requests, one `invalid_grant`) vs `…WithACoordinator_SpendTheRefreshTokenOnce` (the first exchange is held until the second client is waiting on the lock; one exchange, both calls use `at-2`); plus 16 concurrent clients, and 8 concurrent requests through the factory. Stable over repeated runs. - New endpoint tests on `TestServer` (error codes, no message leak, metadata/JWKS served with public keys only, DI wiring), relay revoke on expiry/eviction with a manual clock, loopback address mapping, logout waiting for an in-progress refresh, file-store readers never seeing a missing/partial file under concurrent rewrites. - Widgets: bUnit 2.11.3 (MIT, targets net10.0 / ASP.NET Core 10.0.10, no central package management in the repo, no restore warnings), 30+ tests driving each widget against the real client factory and a stub PDS. - Sample run: `/`, `/login`, CSS asset 200; bad handle → `/login?error=invalid_handle`; AS error → `/login?error=access_denied`; a real `/atproto/login?handle=bsky.app` PARs at bsky.social with `client_id=http://localhost?redirect_uri=http://127.0.0.1:5077/atproto/callback…`. - Benchmarks (Release, scratch console, this machine): - `CreateClientForUserAsync` (OAuth session, in-memory store): **~27 µs → ~4 µs per call, 8,656 → 4,536 B allocated**. - `FileAtProtoSessionStore`, 64 concurrent callers: reads **~50k → ~290k ops/s** (≈5.5×); 10 % writes **~36k → ~280k ops/s**; single caller unchanged (~45–55k). ## Review fixes Seven commits on top of `c1a6f4a`, each with a regression test that fails without it (checked by reverting the fix) and its CHANGELOG change: - **Sign-out no longer brings a session back** (`231f3b2`). Four client paths change the store: sign-out, `ForgetAsync` on expiry, `InstallAsync(persist)` and `UpdateAccountAsync`. All four now take the account's lease after `_transition`, as refresh does. If a sign-out lands during another client's refresh, it waits for the refresh to finish, removes the stored result and revokes the token that refresh produced (`rt-2`), not its own spent copy. `UpdateAccountAsync` writes only while the store still holds its tokens. Tests: sign-out during a held refresh leaves the store empty and revokes `rt-2`; a sign-in during a held refresh keeps the new session. - **External-embed links accept only `http`/`https`** (`4966526`). Any other scheme (`javascript:`, `data:`, …) renders as a `div` with no link. The widgets have no other author-controlled `href`. - **The stored login state is re-checked at the callback** (`86bfd91`). `OAuthLoginState.TryParse` drops a return URL that is not local. It refuses an origin that is not a bare `http(s)` origin, which ends in `login_not_bound`. New test: a login started on a public host with a loopback callback is not relayed. The relay test now also checks that the missing-binding case revokes the session. - **Only the OAuth login's user reaches its stored session** (`3734205`). The factory, the accessor and `/atproto/logout` read the DID only from an identity the login issued (authentication type `ATProto`) or from one carrying `auth_method=oauth`. Service-auth identities are ignored, including on principals that also carry the cookie identity. Custom `ClaimsFactory` principals keep working because they still get the `ATProto` identity type. Listed as a breaking change: principals built by hand need `auth_method=oauth`. - **Cached DPoP keys are dropped when a session ends** (`79a2770`). This happens when a factory client's session is removed or expires, on the hosted logout, and when a request finds the store empty. Only a key matching the ended session is dropped, and it is disposed. - **Cache headers and README** (`24cdeaf`). The served metadata and JWKS carry `Cache-Control: public, max-age=300`. The README's hosting bullet no longer mentions the removed JWT handler. - **Widgets retry, and the accessor re-checks the store** (`e400b87`). `FeedView` and `ProfileCard` mark a load done only after it succeeds. They take the cascading `AuthenticationState`, so a sign-in, a sign-out or a re-render after a failure triggers a reload. `AtProtoUserClientAccessor` gets `IAtProtoSessionStore` in its constructor and reads the store on every call, so a session signed out elsewhere stops being handed out. After review: **4863 passed, 0 failed, 5 skipped**; 0 warnings, 0 errors. ## Deferred / flagged - A relay still waiting at shutdown is dropped, not revoked (the container disposes the `OAuthClient` first); it lasts at most two minutes. - Resolving `IAtProtoClientFactory` before the server has started, with the dev loopback client and no `BaseUrl`, throws (the server's addresses are not known yet); production (`ClientMetadata`) is unaffected. The second half's DI/options work may want to revisit. - The in-process coordinator covers one process; multi-instance deployments need a distributed `ISessionRefreshCoordinator` (documented, not shipped). - `CLAUDE.md` still calls the Server package's auth a "JWT auth handler" and puts the OAuth endpoints in Blazor; left for the owner to edit. - Parallel-PR file touched: `src/ATProtoNet.Server/Spaces/ISpaceCallerResolver.cs` (#133) — one doc `cref` pointed at the deleted handler. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Per-request clients of one user each restore the same session from the
store; when its access token is about to expire, two concurrent requests
both exchanged the single-use refresh token and the authorization server
ended the session. ISessionRefreshCoordinator serializes refreshes per
account, and the client re-reads the store once it holds the lock: a
session another client refreshed is taken up instead of exchanged again,
and one the store no longer holds was signed out and ends here too.

Also: the DPoP key object can be shared by short-lived clients through a
non-owning view (the server factory's key cache builds on it), and
ResolveFromServerUrlAsync passes the protected-resource metadata it read
through instead of resolving it a second time (#167 follow-up).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The OAuth cookie login (AtProtoOAuthService, its options, AddAtProtoAuthentication,
MapAtProtoOAuth, the login binding) moves from ATProtoNet.Blazor to ATProtoNet.Server,
so any ASP.NET Core app can use it. Its OAuthClient is built from the options alone and
registered as the OAuthClient singleton the client factory refreshes with, so stored
sessions refresh right after a restart; IOAuthClientProvider goes. The bearer-JWT
AtProtoAuthenticationHandler, superseded by service auth, is deleted.

The hosted login gains confidential-client keys and can serve its client metadata and
key set. The callback returns a typed result, error redirects carry a code rather than
an exception message, and a relayed login nobody redeems is revoked on expiry or
eviction. AtProtoClaimTypes absorbs the service-auth claim constants.

The client factory refreshes under the new refresh coordinator (registered by
AddAtProtoServer) and caches each account's imported DPoP key; the file session store
reads without a lock and writes under per-account locks.

The Blazor widgets act as the signed-in user through a scoped AtProtoUserClientAccessor
(AddAtProtoBlazor), FeedView takes a typed FeedSource, PostCard likes and reposts on its
own, ProfileCard loads by Actor, and an opt-in stylesheet covers the atproto-* classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FeedView, PostCard, ProfileCard, ComposePost and the scoped user client
are rendered and driven with bUnit 2 (MIT) over the real client factory
and a stub PDS, so each widget is checked to act as the signed-in user.
LoginForm's error codes are covered, and the file store's lock-free read
test waits for the writers so the two really overlap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The OAuth pages describe the hosted login where it now lives, with its
typed callback, error codes, confidential-client keys and the documents
MapAtProtoOAuth can serve; server.md and session-management.md cover the
refresh coordinator; aspnet-core.md replaces the removed bearer handler
with service auth; blazor.md documents AddAtProtoBlazor, the typed
FeedSource, each widget and its CSS classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A timeline carries a post once for each account that reposted it, and
keying the cards by post URI made Blazor refuse the duplicate key. The
loopback-callback error also says that the server's addresses are known
only once it has started. Both refine changes earlier on this branch,
which the CHANGELOG entries already describe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test: read the client's HttpClient through an internal accessor (#136)
All checks were successful
CI / pds-integration (pull_request) Successful in 29s
CI / build-and-test (pull_request) Successful in 1m0s
c1a6f4aaef
The last reflection into the SDK's own fields in the tests; the one left
reads HttpMessageInvoker's handler, which no InternalsVisibleTo reaches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-173/run-2026-09-26T17-01-13-977Z.md) — updating every ~5 minutes while the review runs.
Collaborator

⏳ 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-26T21:57:27.503Z) — nothing to re-trigger.

⏳ **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-26T21:57:27.503Z) — nothing to re-trigger. <!-- opencode-run:paused --> <!-- opencode-session:ses_f21560c0dffe46stk0Y14soGCt -->
Author
Owner

@claude /review — security-relevant: the OAuth cookie login moves from Blazor into ATProtoNet.Server (the #167 CSRF binding, local-only return URLs and bounded pending logins must survive unchanged), the bearer-JWT handler is deleted, and a per-DID refresh coordinator is added. Please read in full from the branch: the moved AtProtoOAuthService and MapAtProtoOAuth in ATProtoNet.Server, ISessionRefreshCoordinator and its use in SessionManager, the client factory's per-DID cache, FileAtProtoTokenStore's locking, the client-metadata and JWKS endpoints, and the Blazor AtProtoUserClientAccessor. Focus on regressions in the moved security checks, refresh races, secrets in redirects, and the JWKS serving only public keys.

@claude /review — security-relevant: the OAuth cookie login moves from Blazor into ATProtoNet.Server (the #167 CSRF binding, local-only return URLs and bounded pending logins must survive unchanged), the bearer-JWT handler is deleted, and a per-DID refresh coordinator is added. Please read in full from the branch: the moved AtProtoOAuthService and MapAtProtoOAuth in ATProtoNet.Server, ISessionRefreshCoordinator and its use in SessionManager, the client factory's per-DID cache, FileAtProtoTokenStore's locking, the client-metadata and JWKS endpoints, and the Blazor AtProtoUserClientAccessor. Focus on regressions in the moved security checks, refresh races, secrets in redirects, and the JWKS serving only public keys.
Collaborator

🤖 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 — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/621); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-173/run-621.md) — updating every ~5 minutes while the task runs.
Author
Owner

@claude /review — retrying, since the previous run didn't post a review (the subscription hit its usage limit). Same focus as above: read the moved OAuth hosting in ATProtoNet.Server in full, plus the refresh coordinator, the client factory cache, FileAtProtoTokenStore locking, the client-metadata and JWKS endpoints, and AtProtoUserClientAccessor. Look for regressions in the moved security checks (CSRF binding, local-only return URLs, bounded pending logins), refresh races, secrets in redirects, and whether the JWKS serves public keys only.

@claude /review — retrying, since the previous run didn't post a review (the subscription hit its usage limit). Same focus as above: read the moved OAuth hosting in ATProtoNet.Server in full, plus the refresh coordinator, the client factory cache, FileAtProtoTokenStore locking, the client-metadata and JWKS endpoints, and AtProtoUserClientAccessor. Look for regressions in the moved security checks (CSRF binding, local-only return URLs, bounded pending logins), refresh races, secrets in redirects, and whether the JWKS serves public keys only.
Collaborator

🤖 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 — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/622); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-173/run-622.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

💬 Verdict: comment — notes only, nothing blocking.

The visible portion of this diff (Blazor package rewrite, docs, samples, CHANGELOG) is well executed and matches the PR description closely. The new AtProtoUserClientAccessor, FeedView/PostCard/ProfileCard/ComposePost rewrites are coherent: the gate-protected accessor correctly avoids leaking a client between different signed-in users, the feed source dispatch via a sealed-record FeedSource is clean, and the like/repost optimistic-state handling in PostCard looks correct (state keyed off ReferenceEquals(Post, _shown) so it survives re-renders of the same post but resets for a new one). Removing the explicit StateHasChanged() calls is safe since Blazor's event-handler dispatch already re-renders after the returned task completes. The textarea self-close fix and grapheme-based composer limit are good catches.

Note up front: the diff supplied to me was truncated partway through the deleted AtProtoOAuthService.cs / new AtProtoAuthenticationExtensions mapping code, so I could not see the actual new ATProtoNet.Server.Authentication implementation (the OAuth service, ISessionRefreshCoordinator/InProcessSessionRefreshCoordinator, the FileAtProtoSessionStore locking rewrite, DPoP key LRU cache, relay revocation timer, ServeClientMetadata, or FeedSource.cs itself). Those are the bulk of the PR's substantive new logic per the description, so this review only speaks to what's visible: the Blazor components, docs, samples and changelog. A follow-up pass over the Server package files (and their tests) would be worth doing before merge if it hasn't already had one.

Two minor, non-blocking observations below on the visible code.


⏱ 1m 54s · 2 turns · tokens: 87.5k in / 9.8k out (+116.3k cache-read)
📜 Full session transcript

💬 **Verdict: comment** — notes only, nothing blocking. The visible portion of this diff (Blazor package rewrite, docs, samples, CHANGELOG) is well executed and matches the PR description closely. The new `AtProtoUserClientAccessor`, `FeedView`/`PostCard`/`ProfileCard`/`ComposePost` rewrites are coherent: the gate-protected accessor correctly avoids leaking a client between different signed-in users, the feed source dispatch via a sealed-record `FeedSource` is clean, and the like/repost optimistic-state handling in `PostCard` looks correct (state keyed off `ReferenceEquals(Post, _shown)` so it survives re-renders of the same post but resets for a new one). Removing the explicit `StateHasChanged()` calls is safe since Blazor's event-handler dispatch already re-renders after the returned task completes. The textarea self-close fix and grapheme-based composer limit are good catches. Note up front: the diff supplied to me was truncated partway through the deleted `AtProtoOAuthService.cs` / new `AtProtoAuthenticationExtensions` mapping code, so I could not see the actual new `ATProtoNet.Server.Authentication` implementation (the OAuth service, `ISessionRefreshCoordinator`/`InProcessSessionRefreshCoordinator`, the `FileAtProtoSessionStore` locking rewrite, DPoP key LRU cache, relay revocation timer, `ServeClientMetadata`, or `FeedSource.cs` itself). Those are the bulk of the PR's substantive new logic per the description, so this review only speaks to what's visible: the Blazor components, docs, samples and changelog. A follow-up pass over the Server package files (and their tests) would be worth doing before merge if it hasn't already had one. Two minor, non-blocking observations below on the visible code. --- ⏱ 1m 54s · 2 turns · tokens: 87.5k in / 9.8k out (+116.3k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-173/run-622.md) <!-- claude-verdict:comment --> <!-- claude-reviewed-head:c1a6f4aaef57d59ee58b8247fa0f63b069e9739b -->
@ -0,0 +58,4 @@
await _gate.WaitAsync(cancellationToken);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
Collaborator

The cached client is reused whenever current.IsAuthenticated is true and the DID still matches, with no check against whether the store still holds a session for that account. The XML doc on this method says a caller gets null again "when... the session ends", but nothing here re-queries the store — it relies entirely on AtProtoClient.IsAuthenticated flipping to false after a failed refresh/revoked session. Worth double-checking that IsAuthenticated is actually updated in that case (e.g. after the coordinator throws XrpcAuthenticationException/InvalidToken on a read), otherwise a long-lived circuit could keep returning a client for a session that was removed from the store elsewhere until the next explicit call that happens to trigger a refresh.

The cached client is reused whenever `current.IsAuthenticated` is true and the DID still matches, with no check against whether the store still holds a session for that account. The XML doc on this method says a caller gets `null` again "when... the session ends", but nothing here re-queries the store — it relies entirely on `AtProtoClient.IsAuthenticated` flipping to false after a failed refresh/revoked session. Worth double-checking that `IsAuthenticated` is actually updated in that case (e.g. after the coordinator throws `XrpcAuthenticationException`/`InvalidToken` on a read), otherwise a long-lived circuit could keep returning a client for a session that was removed from the store elsewhere until the next explicit call that happens to trigger a refresh.
@ -71,0 +105,4 @@
if (Profile is not null || Actor is null)
{
_loaded = null;
_loadedActor = null;
Collaborator

_loadedActor is set to actor before the fetch and never cleared on failure, so if the fetch fails only because UserClient.GetClientAsync() returned null (not signed in yet), the component will not retry on a later render with the same Actor even after the user signs in — the Actor == _loadedActor guard on the next OnParametersSetAsync short-circuits before ever calling GetClientAsync() again. Same pattern applies to FeedView's _loadedSource/_loadedPageSize guard. In practice the OAuth flow here is a full-page redirect that remounts components, so this is unlikely to bite, but if these widgets are ever reused in a scenario where sign-in happens without navigating away (e.g. a modal login within the same circuit), the card/feed would stay stuck on the sign-in prompt until Actor/FeedSource changes.

`_loadedActor` is set to `actor` before the fetch and never cleared on failure, so if the fetch fails only because `UserClient.GetClientAsync()` returned null (not signed in yet), the component will not retry on a later render with the same `Actor` even after the user signs in — the `Actor == _loadedActor` guard on the next `OnParametersSetAsync` short-circuits before ever calling `GetClientAsync()` again. Same pattern applies to `FeedView`'s `_loadedSource`/`_loadedPageSize` guard. In practice the OAuth flow here is a full-page redirect that remounts components, so this is unlikely to bite, but if these widgets are ever reused in a scenario where sign-in happens without navigating away (e.g. a modal login within the same circuit), the card/feed would stay stuck on the sign-in prompt until `Actor`/`FeedSource` changes.
Author
Owner

@claude /review — re-review please. Seven new commits (231f3b2..e400b87) on top of c1a6f4a fix an independent adversarial review plus your two notes; see "Review fixes" in the description. Please read those commits in full (git diff c1a6f4a..e400b87) and the Server authentication files you couldn't see last time:

  • SessionManager store changes now take the refresh lease (logout, expire, install, account update), with a 30 s wait fallback; sign-out also revokes a token refreshed concurrently.
  • Only an OAuth-login identity (ATProto auth type or auth_method=oauth) reaches a stored session through the factory, the accessor and logout.
  • IsLocalUrl is re-checked on the stored login state at the callback.
  • The new relay-origin test.
  • External-embed links are limited to http(s).
  • The DPoP key cache is evicted when a session ends.
  • Cache-Control on the client metadata and JWKS.
  • The widgets retry after sign-in, and the accessor re-reads the store.
@claude /review — re-review please. Seven new commits (231f3b2..e400b87) on top of c1a6f4a fix an independent adversarial review plus your two notes; see "Review fixes" in the description. Please read those commits in full (`git diff c1a6f4a..e400b87`) and the Server authentication files you couldn't see last time: - `SessionManager` store changes now take the refresh lease (logout, expire, install, account update), with a 30 s wait fallback; sign-out also revokes a token refreshed concurrently. - Only an OAuth-login identity (`ATProto` auth type or `auth_method=oauth`) reaches a stored session through the factory, the accessor and logout. - `IsLocalUrl` is re-checked on the stored login state at the callback. - The new relay-origin test. - External-embed links are limited to http(s). - The DPoP key cache is evicted when a session ends. - Cache-Control on the client metadata and JWKS. - The widgets retry after sign-in, and the accessor re-reads the store.
A client's sign-out removed the store entry without the account's lease,
so another client mid-refresh wrote its new tokens back afterwards and the
signed-out session came back on the next request. Installing a session,
updating its account details and forgetting a refused one had the same
gap. All four now take the lease after the transition lock, as a refresh
does; a sign-out revokes the tokens a concurrent refresh produced, and an
account-details update no longer overwrites a newer stored copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The external embed's href came straight from the post author's record,
so a javascript: URL ran in the app's origin on click. Only absolute
http(s) URLs are rendered as links now; the widgets have no other
author-controlled href (post text is rendered without facets, and the
profile card has no links).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The return URL and origin of a login come back from the state store and
decide the redirect, so they are checked again when read: a return URL
that is not local falls back to DefaultReturnUrl, and an origin that is
not a bare http(s) origin fails the login rather than being relayed.

Tests now pin the relay's loopback check with a login started on a public
host and a loopback callback, and the relay's revocation when the
redeeming browser lacks the binding cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client factory, the Blazor accessor and sign-out took the DID from any
did claim, and service auth issues that claim for whoever holds a token
naming the DID. On an endpoint accepting both schemes, a service auth
caller could act through the account's stored OAuth session, with all its
scopes, or sign it out. They now read the DID only from an identity the
OAuth login issued (authentication type ATProto) or one carrying
auth_method=oauth; the login's own principals, custom ClaimsFactory
included, keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client factory kept an account's imported DPoP key after sign-out.
A factory client's sign-out or refused session, the hosted /atproto/logout,
and a request finding the session gone from the store now drop the cached
key and dispose it, so no client of the ended session signs with it again.
Only a key matching the ended session is dropped, so a new sign-in's key
is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client metadata and key set MapAtProtoOAuth serves carry
Cache-Control: public, max-age=300, short enough for a rotated-in key to
reach authorization servers in minutes. The README's hosting bullet no
longer names the removed JWT handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(blazor): widgets retry after sign-in, and a signed-out session stops being handed out after review (#136)
All checks were successful
CI / pds-integration (pull_request) Successful in 29s
CI / build-and-test (pull_request) Successful in 57s
e400b870cc
FeedView and ProfileCard marked a load done before it ran, so a widget
that first rendered signed out, or failed, never tried again. They now
count a load only once it succeeds, and take the cascading authentication
state, so a sign-in, a sign-out or a parent's re-render after a failure
loads again.

AtProtoUserClientAccessor reused its client while the DID matched, even
after the session was signed out in another tab or request. It now checks
the session store on every call and drops the client when the session is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 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 — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/624); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-173/run-624.md) — updating every ~5 minutes while the task runs.
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-173/run-2026-09-26T20-54-09-465Z.md) — updating every ~5 minutes while the review runs.
Collaborator

⏳ 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-27T01:54:09.868Z) — nothing to re-trigger.

⏳ **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-27T01:54:09.868Z) — nothing to re-trigger. <!-- opencode-run:paused --> <!-- opencode-session:ses_f2080ca82ffeXC7WdnQ8MRjEon -->
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Re-review after the seven review-fix commits landed on top of my prior pass.

Both issues I raised previously are resolved, each with a regression test:

  • AtProtoUserClientAccessor staleness (my prior comment on AtProtoUserClientAccessor.cs:61): commit e400b87 now has GetClientAsync re-check IAtProtoSessionStore on every call instead of trusting client.IsAuthenticated, dropping the cached client when the store no longer holds the session. AtProtoUserClientAccessorTests.cs covers a session removed elsewhere while the client is still cached.
  • FeedView/ProfileCard stuck on a failed-before-signed-in load (my prior comment on ProfileCard.razor:108): the same commit changes both components to mark a load done only once it succeeds, and to key off the cascading AuthenticationState as well as their own parameters, so a sign-in, sign-out, or a parent re-render after a failure retries instead of latching on the first (failed or signed-out) attempt. New tests in FeedViewTests.cs/ProfileCardTests.cs pin this.

I also read through the previously-truncated ATProtoNet.Server.Authentication and core-auth substance that wasn't visible in my last pass (I was only able to review Blazor/docs/samples/CHANGELOG then): AtProtoOAuthService (client construction, callback/relay/logout flows, relay timer-based revocation and eviction), SessionManager's refresh-coordinator integration (single-flight refresh, lease acquisition around install/update/expire/logout, adopting another client's refreshed session, ending a session the store no longer holds), InProcessSessionRefreshCoordinator, and AtProtoClientFactory's DPoP key LRU cache. All of this is careful, consistent with the PR description, and the tricky concurrency invariants (single-use refresh token, no reviving a signed-out session, cached key torn down only when it matches the ended session) are reasoned through correctly and covered by the described regression tests (e.g. the two-client refresh race tests, sign-out-during-refresh tests).

The other five review-fix commits (4966526 external-embed scheme allowlist, 86bfd91 re-validating the stored login state's origin/return-URL at the callback, 3734205 restricting factory/accessor/logout to the OAuth login's own identity, 79a2770 dropping the cached DPoP key on session end, 24cdeaf cache headers) all read correctly and match their CHANGELOG entries.

No new blocking issues found. Approving.


⏱ 1m 32s · 29 turns · tokens: 129.7k in / 5.9k out (+3191.3k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Re-review after the seven review-fix commits landed on top of my prior pass. Both issues I raised previously are resolved, each with a regression test: - **AtProtoUserClientAccessor staleness** (my prior comment on `AtProtoUserClientAccessor.cs:61`): commit `e400b87` now has `GetClientAsync` re-check `IAtProtoSessionStore` on every call instead of trusting `client.IsAuthenticated`, dropping the cached client when the store no longer holds the session. `AtProtoUserClientAccessorTests.cs` covers a session removed elsewhere while the client is still cached. - **FeedView/ProfileCard stuck on a failed-before-signed-in load** (my prior comment on `ProfileCard.razor:108`): the same commit changes both components to mark a load done only once it succeeds, and to key off the cascading `AuthenticationState` as well as their own parameters, so a sign-in, sign-out, or a parent re-render after a failure retries instead of latching on the first (failed or signed-out) attempt. New tests in `FeedViewTests.cs`/`ProfileCardTests.cs` pin this. I also read through the previously-truncated `ATProtoNet.Server.Authentication` and core-auth substance that wasn't visible in my last pass (I was only able to review Blazor/docs/samples/CHANGELOG then): `AtProtoOAuthService` (client construction, callback/relay/logout flows, relay timer-based revocation and eviction), `SessionManager`'s refresh-coordinator integration (single-flight refresh, lease acquisition around install/update/expire/logout, adopting another client's refreshed session, ending a session the store no longer holds), `InProcessSessionRefreshCoordinator`, and `AtProtoClientFactory`'s DPoP key LRU cache. All of this is careful, consistent with the PR description, and the tricky concurrency invariants (single-use refresh token, no reviving a signed-out session, cached key torn down only when it matches the ended session) are reasoned through correctly and covered by the described regression tests (e.g. the two-client refresh race tests, sign-out-during-refresh tests). The other five review-fix commits (`4966526` external-embed scheme allowlist, `86bfd91` re-validating the stored login state's origin/return-URL at the callback, `3734205` restricting factory/accessor/logout to the OAuth login's own identity, `79a2770` dropping the cached DPoP key on session end, `24cdeaf` cache headers) all read correctly and match their CHANGELOG entries. No new blocking issues found. Approving. --- ⏱ 1m 32s · 29 turns · tokens: 129.7k in / 5.9k out (+3191.3k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-173/run-624.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:e400b870ccfe73fa589aea35703982856002c6ce -->
Grandiras force-pushed issue-136-auth-hosting from e400b870cc
All checks were successful
CI / pds-integration (pull_request) Successful in 29s
CI / build-and-test (pull_request) Successful in 57s
to b4ae539a7a
All checks were successful
CI / pds-integration (pull_request) Successful in 30s
CI / build-and-test (pull_request) Successful in 54s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 9s
2026-09-26 20:55:32 +00:00
Compare
Author
Owner

Approval carry-over check: claude-reviewer approved e400b87. After #172 merged I rebased onto main, and CHANGELOG.md was the only conflict. verify-rebase: the patch against the merge base, excluding CHANGELOG.md, is byte-identical between e400b87 and b4ae539 (8,344 changed lines). CI is green on b4ae539. Locally, the build has 0 warnings and 4,991 tests pass. Merging on the carried-over approval.

Approval carry-over check: claude-reviewer approved e400b87. After #172 merged I rebased onto main, and CHANGELOG.md was the only conflict. `verify-rebase`: the patch against the merge base, excluding CHANGELOG.md, is byte-identical between e400b87 and b4ae539 (8,344 changed lines). CI is green on b4ae539. Locally, the build has 0 warnings and 4,991 tests pass. Merging on the carried-over approval.
Sign in to join this conversation.
No description provided.