• v0.5.0 fab0e9fbdd

    v0.5.0
    All checks were successful
    CI / pds-integration (push) Successful in 17s
    CI / build-and-test (push) Successful in 46s
    Release / release (push) Successful in 58s
    Stable

    Grandiras released this 2026-07-25 22:43:20 +00:00 | 19 commits to main since this release

    Breaking changes

    • ATProtoNet.Pds package removed — The in-process PDS implementation is gone; this project does not maintain a PDS. Use ATProtoNet.Aspire.Hosting to run the official Bluesky PDS container (ghcr.io/bluesky-social/pds) and the new PdsAdminClient to administer it — see Added below and docs/managed-pds.md. There is no in-process replacement for AddAtProtoPds() / MapAtProtoPds(), IAccountStore, IRepoStore, IRepoCommitStore, IInviteCodeStore, PdsService, or the EF Core stores; an app that hosted a PDS in-process must run the reference implementation instead. The published set drops from 6 packages to 5
    • ATProtoNet.Aspire.Hosting now targets Aspire 13 — Package reference moved from Aspire.Hosting 9.5.2 to 13.4.6. AppHosts referencing it must be on Aspire 13
    • AtProtoPdsContainerResource constructor gained three ParameterResource arguments(name, adminPassword, jwtSecret, plcRotationKey). Source- and binary-breaking for anyone constructing the resource directly; AddAtProtoPds() is unaffected
    • ATProtoNet.Server.EntityFrameworkCore package merged into ATProtoNet.Server (Issue #33) — The EF Core-backed IAtProtoTokenStore now ships inside ATProtoNet.Server. Remove the ATProtoNet.Server.EntityFrameworkCore <PackageReference> (it is replaced by ATProtoNet.Server); the ATProtoNet.Server.EntityFrameworkCore namespace, AddAtProtoEfCoreTokenStore<TContext>(), AtProtoTokenDbContext, and AtProtoTokenEntity are unchanged, so only the package reference changes. ATProtoNet.Server now transitively depends on Microsoft.EntityFrameworkCore.Relational
    • ATProtoNet.Aspire package merged into ATProtoNet.Server (Issue #33) — The .NET Aspire client integration now ships inside ATProtoNet.Server. Remove the ATProtoNet.Aspire <PackageReference> (it is replaced by ATProtoNet.Server); the ATProtoNet.Aspire namespace, AddAtProtoClient(...), AtProtoClientSettings, and AtProtoPdsHealthCheck are unchanged. ATProtoNet.Server now depends on Microsoft.Extensions.Http.Resilience

    Added

    • Managed PDS — Run the official Bluesky PDS as a container your app owns, and administer it from .NET. This replaces the removed in-process PDS: the reference implementation does the serving, ATProto.NET does the orchestration and administration
      • PdsAdminClient (core ATProtoNet, namespace ATProtoNet.Admin) — administers any PDS you hold the admin password for, authenticating with HTTP Basic as the reference PDS expects. CreateAccountAsync is the headline: it calls describeServer, mints an invite code with the admin credentials when the server requires one, and then signs the account up — so an app can provision accounts on its own server rather than sending users to an external provider. The signup call itself is sent unauthenticated, so the admin password is never attached to a public endpoint. Also wraps invite codes (CreateInviteCodeAsync, CreateInviteCodesAsync), account lookup, takedown/restore, handle/email/password updates, and deletion; Admin and Server expose the raw admin-authenticated clients for everything else. The constructor rejects a non-loopback http:// URL rather than sending the admin password in the clear
      • PdsAdminOptions.AllowInsecureHttp — opt-in for reaching the PDS over plaintext HTTP at a non-loopback host, the shape an Aspire container network produces. Default false; the guard validates the effective base address, so supplying an HttpClient whose BaseAddress differs from the configured URL cannot slip past it
      • AddAtProtoPdsAdmin() (ATProtoNet.Server) — registers PdsAdminClient as a typed HttpClient, binding AtProto:Pds:Url, AtProto:Pds:AdminPassword, and AtProto:Pds:AllowInsecureHttp (the keys the Aspire integration supplies). Overloads take explicit credentials or a PdsAdminOptions. Missing configuration throws while the host is built, naming the key; the client's own validation — such as the refusal to send the admin password over plaintext HTTP — runs when it is first resolved. Registering it as a typed client rather than a singleton over one captured HttpClient means the factory rotates the underlying handler, so a long-running deployment picks up DNS changes behind the PDS URL
      • WithAtProtoPds(pds) (ATProtoNet.Aspire.Hosting) — wires a project to the PDS container in one call: WithReference, the configuration keys above, and WaitFor on the container's health check. In run mode it also sets AtProto__Pds__AllowInsecureHttp, because a containerized consumer resolves the PDS over the container network rather than at a loopback address and would otherwise be rejected by the very URL this method supplies. It does not set it when publishing: sending the admin password unencrypted across a deployed network is left as the operator's explicit decision
      • WithHandleDomains, WithInviteCodeRequired, WithAdminPassword, WithJwtSecret, WithPlcRotationKey, WithDataBindMount — new configuration methods on the PDS resource. AdminPasswordParameter, JwtSecretParameter, and PlcRotationKeyParameter are exposed on AtProtoPdsContainerResource
      • The container now gets an HTTP health check on /xrpc/_health, so WaitFor(pds) works, and PDS_HOSTNAME defaults to localhost so it starts without further configuration
      • XrpcClient.SetAdminCredentials(password, user = "admin") / ClearAdminCredentials() / HasAdminCredentials — HTTP Basic admin auth on the low-level client. A session token still takes priority when both are set
      • WithHostname gained a ParameterResource overload, and the PDS hostname is now resolved through the resource like the other settings. Running locally it defaults to localhost; when publishing there is no sensible default, so AddAtProtoPds creates a {name}-hostname parameter for the deployment to supply. The hostname fixes the server's did:web identity and the domain new handles are created under, so a PDS deployed as localhost would issue identities nothing can resolve
      • PDS_DEV_MODE=true is likewise set only when running locally. Left unset in a published manifest, the container defaults it off, so dev-mode's relaxed checks cannot reach a deployment by inheritance
      • Overriding a generated parameter now removes it from the application modelWithHostname, WithJwtSecret, WithPlcRotationKey, and WithAdminPassword create their parameters before any override can run, so a superseded one stayed in the model and appeared in a published manifest as an input. A deployment was prompted for a hostname or secret nothing would read
      • CI now runs both untested seams. A pds-integration job starts the real ghcr.io/bluesky-social/pds container as a service and runs PdsAdminTests (gated by [RequiresPdsAdminFact] / ATPROTO_PDS_ADMIN_PASSWORD) against it: describe, invite minting, account provisioning, handle updates, takedown/restore, deletion, and signing in with the session a provisioned account was handed. A second step publishes the AppHost sample's manifest and asserts on it with AspireManifestTests (gated by ATPROTO_ASPIRE_MANIFEST), covering the publish path that needs no container runtime. Both jobs set ATPROTO_REQUIRE_INTEGRATION=1, which turns a skipped gate into a failure: dotnet test --filter exits 0 when every test it matched skipped, so a drifted environment variable would otherwise leave a green check that verified nothing — the exact failure this PR exists to correct
      • New documentation page docs/managed-pds.md and samples samples/ManagedPdsSample (a signup API built on PdsAdminClient) and samples/ManagedPdsSample.AppHost (the Aspire AppHost wiring it to a PDS container). The AppHost can emit its manifest without starting anything — dotnet run --project samples/ManagedPdsSample.AppHost -- --publisher manifest --output-path manifest.json — which is how the publish-mode behaviour above is verified against real Aspire rather than only at the app-model level
    • CarWriter (Issue #40) — CAR v1 producer, the counterpart to CarReader. Write(root, blocks) for a block dictionary keyed by base32 CID (the shape MerkleSearchTree.Serialize() returns) or an explicit CarBlock sequence, plus WriteTo/WriteToAsync for streaming
    • RepoCommit / SignedRepoCommit (Issue #40) — builds and signs AT Protocol commit objects. EncodeUnsigned() produces exactly the bytes that get signed, and the encoding is a byte-for-byte prefix-preserving superset, so FirehoseVerifier.ExtractSignedView recovers them intact — that round-trip is asserted in the tests
    • PlcOperationBuilder (Issue #40) — builds, signs and derives DIDs from did:plc genesis operations, with PlcClient.SubmitOperationAsync to publish them. Adds PlcErrorKind.InvalidOperation
    • MerkleSearchTree.SerializeProof(keys) (Issue #40) — serializes only the root and the nodes on the root→key search paths, the covering proof a firehose #commit or a com.atproto.sync.getRecord response carries. Serialize() (the whole tree) is unchanged
    • Tid.FromInt64(long) and Tid.ToInt64() (Issue #40) — convert between a TID and its raw 64-bit value, so callers needing a strictly increasing sequence can mint one themselves
    • CidComputation.TryDecodeCidString (Issue #40) — non-throwing CID string decoding
    • DidDocument.Context (Issue #40) — the @context field, omitted when serializing unless set. Required when publishing a document (a did:web /.well-known/did.json), ignorable when consuming one
    • Jetstream consumer (Issue #43) — JSON event streaming with server-side filtering, the bandwidth-friendly alternative to the binary firehose for indexing specific collections
      • JetstreamClient — single WebSocket connection to a Jetstream instance's /subscribe endpoint with wantedCollections (NSIDs or prefix wildcards, max 100), wantedDids (max 10,000), cursor (unix microseconds), and maxMessageSizeBytes support
      • JetstreamConsumer — managed consumer with automatic reconnection (backoff, MaxReconnectAttempts), cursor persistence through the existing IFirehoseCursorStore (cursor = time_us), reconnect rewind (ReconnectRewind, default 5 s) with duplicate suppression, and at-least-once delivery semantics across restarts
      • JetstreamEventParser — forward-tolerant parser for commit/identity/account event kinds; unknown kinds, operations, and fields are skipped instead of throwing
      • JetstreamCommitEvent.GetRecord<T>() — typed record deserialization honouring LexiconTypeRegistry registrations; computed Uri (at://did/collection/rkey)
      • IJetstreamDecompressor — optional zstd seam; the SDK ships no zstd dependency, docs/jetstream.md includes a copy-paste ZstdSharp.Port implementation
      • Jetstream events carry no MST proofs or signatures and cannot be cryptographically verified (documented; use the binary firehose where verification matters)
      • New documentation page docs/jetstream.md incl. Jetstream-vs-firehose comparison table
    • AuthorizationServerDiscovery.HandleResolutionTimeout (Issue #52) — Per-round budget for handle resolution, enforced by a CancellationTokenSource linked to the caller's token. Default 5 s (AuthorizationServerDiscovery.DefaultHandleResolutionTimeout); Timeout.InfiniteTimeSpan restores the old unbounded behaviour. Configurable through the new OAuthOptions.HandleResolutionTimeout and AtProtoOAuthServerOptions.HandleResolutionTimeout
    • AtProtoOAuthServerOptions.HttpClient (Issue #52) — Lets a consuming app supply the HttpClient used for OAuth discovery and token requests (e.g. an IHttpClientFactory client, a proxy, custom handlers). The supplied client's Timeout is left untouched and it is not disposed with the service
    • AtProtoOAuthServerOptions.HttpClientTimeout (Issue #52) — Timeout applied to the SDK-created OAuth HttpClient. Default 30 s
    • OAuthClientMetadata.ToJson(bool writeIndented = false) (Issue #41) — Renders the client-metadata document exactly as it must be served at the client_id URL, with unset optional fields omitted. app.MapGet("/client-metadata.json", () => Results.Content(metadata.ToJson(), "application/json"))
    • AtProtoJsonDefaults.ApplyRecordTypeDiscriminator(JsonTypeInfo) (Issue #49) — Public JsonTypeInfo contract modifier that guarantees AtProtoRecord-derived types serialize their Lexicon type as exactly one $type property. Applied automatically by the SDK's own serializer options; expose it to hand-built JsonSerializerOptions via DefaultJsonTypeInfoResolver.Modifiers
    • Typed unions, nested inline objects, and token families in atproto-lexgen csharp (Issue #45)
      • A Lexicon union whose variants are all object defs in the generation run now emits an abstract class <Property>Union with [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] and one [JsonDerivedType] per variant, and the variants subclass it — so attribution round-trips as AttributionWebsite instead of a raw JsonElement. Unions whose variants another union already claimed still fall back to JsonElement? (a C# class has one base) and say so as a WARN
      • A union of tokens is typed as string — the value on the wire is the token NSID
      • Inline object schemas become nested classes (RecipeRecord.NutritionInfo); an array of inline objects singularizes its element type (List<Ingredient>)
      • knownValues/token families collapse into one static class of constants per family plus an All list (CookingMethod.Baking, Diet.Vegan) instead of one static class per token — the recipe.exchange defs document drops from 101 generated classes to 9
      • CSharpEmitter.Warnings exposes the diagnostics; atproto-lexgen csharp prints them as WARN lines

    Removed

    • ATProtoNet.Pds NuGet package, samples/PdsSample, and docs/pds.md — See Breaking changes above. The implementation remains in git history; it was dropped because maintaining a second PDS implementation was never the goal, and the reference implementation is the one the network actually federates with
    • ATProtoNet.Server.EntityFrameworkCore and ATProtoNet.Aspire NuGet packages (Issue #33) — Consolidated into ATProtoNet.Server, cutting the published set from 8 packages to 6. See Breaking changes above for the (reference-only) migration

    Fixed

    • WithDataBindMount produced a container that could not startAddAtProtoPds() always mounts a named volume at /pds, and WithDataBindMount added a second mount on the same destination. Both annotations reached the container spec, which Docker and Podman reject outright (Error: /pds: duplicate mount destination), so the documented AddAtProtoPds("pds").WithDataBindMount("./pds-data") never came up. Supplying a data mount now replaces the default one instead of adding to it. WithDataVolume(name?) is added as the counterpart, for renaming the volume or restoring it after a bind mount
    • The Aspire PDS container never startedAddAtProtoPds() set PDS_DATA_DIRECTORY but no blobstore, and the reference PDS exits during startup with Must configure either S3 or disk blobstore. It now sets PDS_BLOBSTORE_DISK_LOCATION to /pds/blocks, under the same data volume, so blobs persist with the rest of the server's state. Present since the integration was introduced in 0.4.0, and not caught earlier because nothing ran the container
    • Seven AdminClient methods threw JsonException against a real PDSDeleteAccountAsync, UpdateAccountHandleAsync, UpdateAccountEmailAsync, UpdateAccountPasswordAsync, DisableAccountInvitesAsync, EnableAccountInvitesAsync, and DisableInviteCodesAsync requested a deserialized response (ProcedureAsync<TRequest, object>) from endpoints the reference PDS answers with an empty body, so every one of them failed with The input does not contain any JSON tokens no matter what the server did. They now use the body-only ProcedureAsync<TRequest> overload. This also affected the corresponding PdsAdminClient wrappers. The same defect affected 19 further procedures elsewhere in the client API; that fix was tracked separately in Issue #69 (see below) so this change stayed scoped to the managed-PDS work it supports
    • Aspire PDS container regenerated its JWT secret and PLC rotation key on every runAddAtProtoPds() generated both with RandomNumberGenerator while the AppHost graph was being built, so each run produced fresh values, but the data volume it mounts persists. Every restart therefore invalidated all existing sessions and, worse, left the accounts already in the volume with did:plc identities whose rotation key the server no longer held. Both are now Aspire parameters generated as lowercase hex and persisted to the AppHost's user secrets — stable across runs, matching the volume's lifetime. Override them with WithJwtSecret / WithPlcRotationKey for production. Existing volumes were written under a key that was already lost each run, so a PDS volume from a previous version should be recreated rather than reused. Generation happens in run mode only: the PDS reads both as hex and an Aspire manifest's generate instruction can only describe an alphanumeric string, so in publish mode the two parameters carry no default and the value must be supplied at deploy time rather than being generated into something the server would reject
    • XRPC procedures with no declared output threw JsonException on every call (Issue #69) — 19 methods across IdentityClient, SyncClient, ActorClient, GraphClient, NotificationClient, and the Ozone communication/team/set clients asked for a deserialized response (ProcedureAsync<TRequest, object>) from endpoints whose Lexicon declares no output, so the empty body a real server returns failed with The input does not contain any JSON tokens before the caller saw anything. They failed on every call regardless of what the server did — and this is not a corner of the API: PutPreferencesAsync, the six mute/unmute methods, UpdateSeenAsync, RegisterPushAsync, and UpdateHandleAsync are ordinary client calls. All now use the body-only ProcedureAsync<TRequest> overload that already existed. No public signatures change, since each of these methods already returned Task and discarded the value
      • The defect survived because the test doubles returned {}, which deserializes perfectly well. EmptyResponseBodyTests drives all 19 NSIDs through a handler that answers 200 with an empty body, the way a real server does
    • LoginForm threw when the app had not called services.AddLocalization() (Issue #35) — The IStringLocalizer<LoginForm> support added in 0.4.0 is documented as optional, but it was wired up with [Inject], and Blazor's property injection requires the service regardless of the property's nullable annotation. Rendering <LoginForm /> in an app without localization registered threw InvalidOperationException: Cannot provide a value for property 'Localizer' … There is no registered service of type 'IStringLocalizer\1[…LoginForm]'before parameters were applied, so passing explicitButtonText/HandlePlaceholdervalues did not avoid it. The localizer is now resolved throughIServiceProvider.GetService<IStringLocalizer>(), so it is genuinely optional: without it the built-in English defaults render, with it the copy is localized exactly as before. The services.AddLocalization()` workaround remains valid
    • atproto-lexgen csharp emitted C# that did not compile (Issue #45) — Found by generating the published exchange.recipe.* Lexicons (recipe.exchange) for a third-party appview. All of the following are fixed, with unit coverage in CSharpEmitterTests:
      • Stray closing brace — every generated file ended with an extra } after the file-scoped namespace, so nothing compiled
      • CS0542 member/type name collisions — a blob property image inside the image def generated BlobRef Image inside class Image. Members that collide with their enclosing type (or with another member, or with AtProtoRecord's Type/CreatedAt) are renamed — ImageBlob, …Ref, …List, …Value — while [JsonPropertyName] keeps the wire format unchanged. Lexicon names that are not legal identifiers (2fa, kebab-case, C# keywords) are sanitized
      • CS0101 duplicate types — sibling documents share a C# namespace, so two #appPassword defs under com.atproto.server collided; later defs are now prefixed with their document name and the rename is reported
      • Unqualified BlobRef/SDK types — generated files now emit using ATProtoNet; / using ATProtoNet.Models; only when needed, plus #nullable enable and using System.Collections.Generic;. Cross-namespace references are rooted at global:: so a generated namespace such as Bsky.Generated.Chat.Bsky.Actor no longer shadows the reference
      • Non-nullable JsonElement fallbacks — optional members are always nullable; serializing default(JsonElement) (ValueKind.Undefined) threw
      • Cross-namespace refs landed in the consumer's namespaceapp.bsky.embed.defs#aspectRatio generated <Prefix>.App.Bsky.Embed.AspectRatio (a type that does not exist) instead of the SDK's ATProtoNet.Lexicon.App.Bsky.Embed.AspectRatio. Well-known com.atproto.*/app.bsky.* defs now map to the SDK's own models (SdkTypeMap); refs that resolve to nothing fall back to JsonElement? and are reported as WARN rather than emitting a dangling type name
      • record defs did not extend AtProtoRecord — they re-declared $type/createdAt and missed the SDK's record ergonomics (GetCollection<T>()). They now subclass AtProtoRecord, override Type, and inherit CreatedAt
      • atproto NSID segment cased as Atproto — generated namespaces/folders now read Com.AtProto.*, matching the SDK layout and NsidToNamespace's documented behaviour
      • Lexicon "type": "number" (used in real-world schemas though absent from the spec) maps to double instead of JsonElement
    • [JsonPropertyName("$type")] is now repeated on every Type override (Issue #45) — System.Text.Json does not carry the attribute from the abstract AtProtoRecord.Type onto an override, so records serialized with hand-built JsonSerializerOptions (i.e. without the Issue #49 contract modifier below) emitted a spurious "type" field. The attribute is now emitted by atproto-lexgen csharp and repeated in the RecordCollection/README/docs examples and the test fixtures, so the documented pattern is correct under any serializer options; RecordCollectionTests locks the behaviour in
    • OAuthClientMetadata serialized unset optional fields as JSON null, so authorization servers rejected the client-metadata document (Issue #41) — The AT Protocol OAuth spec distinguishes absent from null, and the reference @atproto/oauth-provider fails a document containing "jwks_uri": null / "logo_uri": null / "token_endpoint_auth_signing_alg": null with invalid_client_metadata, breaking PAR for any app that served Results.Json(metadata) at its client_id URL. Every optional property on OAuthClientMetadata (client_name, client_uri, logo_uri, tos_uri, policy_uri, token_endpoint_auth_signing_alg, jwks, jwks_uri) and on the nested JsonWebKey (crv, x, y, kid, use, alg) now carries [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)], so the document is spec-compliant under any JsonSerializerOptions — including the ASP.NET Core defaults — with no consumer change. The DefaultIgnoreCondition = WhenWritingNull workaround remains valid
    • AtProtoRecord subclasses serialized a stray type property alongside $type (Issue #49) — AtProtoRecord.Type is abstract and carries [JsonPropertyName("$type")] on the base member. System.Text.Json neither inherits that attribute through an override nor collapses the base member and the override into one contract property, so a record written the documented way (public override string Type => "com.example.todo.item";) emitted both a correct "$type" and a stray camelCased "type" — polluting records that other AT Protocol apps read. A new contract modifier, AtProtoJsonDefaults.ApplyRecordTypeDiscriminator, now collapses those duplicates to a single leading "$type" on every AtProtoRecord-derived type. It is wired into AtProtoJsonDefaults.Options and LexiconTypeRegistry.CreateOptions(), so RecordCollection<T> and RepoClient writes are fixed with no consumer change; the workaround of re-declaring [JsonPropertyName("$type")] on the override remains safe. Add the modifier to your own DefaultJsonTypeInfoResolver.Modifiers if you serialize records with hand-built JsonSerializerOptions
    • Implementing IAtProtoUnion broke all (de)serialization of records containing the union (Issue #46) — The internal UnionJsonConverterFactory registered in AtProtoJsonDefaults.Options and LexiconTypeRegistry.CreateOptions() claimed every type assignable to IAtProtoUnion from CanConvert but returned null from CreateConverter, which System.Text.Json rejects with InvalidOperationException: The converter 'ATProtoNet.Serialization.UnionJsonConverterFactory' cannot return a null value. Marking a custom open-union base with the interface therefore threw on every read and write touching it. The factory never did anything — union discrimination comes from [JsonPolymorphic]/[JsonDerivedType] plus LexiconTypeRegistry.RegisterUnionVariant — so it has been removed. IAtProtoUnion remains as a behaviour-free documentation marker; docs/custom-records.md gains a "Union Types" section covering closed and open unions
    • PlcClient DID-path requests bypassed the directory BaseAddress (Issue #47) — did:plc:… strings parse as absolute URIs (scheme did), so ResolveDidAsync, GetOperationLogAsync, GetAuditLogAsync, GetLastOperationAsync, and GetPlcDataAsync all failed with NotSupportedException: The 'did' scheme is not supported instead of querying https://plc.directory/<did>. Requests now use RFC 3986 ./-prefixed relative references; regression tests added (the class previously had no unit coverage)
    • OAuth handle resolution no longer stalls on a dead handle domain (Issue #52) — Starting the OAuth flow for a handle whose domain silently drops packets on port 443 blocked for the full 100 s HttpClient default before the flow continued, so sign-in appeared to hang. ResolveHandleToDidAsync now races the HTTPS well-known lookup against the DNS-over-HTTPS TXT lookup (first DID wins) instead of trying them in sequence, and both it and ResolveHandleAuthoritativeAsync bound each round with HandleResolutionTimeout (5 s by default) so one unresponsive authority cannot dominate the flow. Caller cancellation still propagates; only budget expiry is treated as "no answer". The appview fallback gets its own fresh budget
    • SDK-created OAuth HttpClient no longer inherits the 100 s default timeout (Issue #52) — AtProtoOAuthService now applies AtProtoOAuthServerOptions.HttpClientTimeout (30 s) to the client it creates, and only overwrites the User-Agent header when one isn't already set. DidWebResolver's parameterless constructor applies a 10 s timeout for the same reason (the target host is embedded in the DID). AtProtoClient's own client is unchanged — it carries blob uploads, where 100 s can be legitimate; pass a pre-configured HttpClient to shorten it
    • A timed-out handle probe no longer aborts OAuth sign-in (Issue #42) — Handle verification in CompleteAuthorizationAsync is best-effort: it distinguished failures by exception type, so a refused connection (HttpRequestException) left IsHandleVerified = false while a timed-out probe (TaskCanceledException — a parked handle domain, or any host when the HttpClient has a ConnectTimeout) propagated out and failed the whole login, even though the authoritative DID from the token response's sub was already in hand. Both now yield an unverified handle; only the caller's own CancellationToken still aborts the flow (and now disposes the pending DPoP key when it does). VerifyDidToAuthServerConsistencyAsync likewise reports caller cancellation as cancellation rather than wrapping it in an auth_server_mismatch-style OAuthException; probe timeouts there still fail closed
    • Polymorphic payloads with a non-leading $type failed to deserialize (Issue #50) — The Bluesky appview serializes embed views (and real-world writers serialize record-internal unions) with the $type discriminator anywhere in the object, not necessarily first; System.Text.Json requires AllowOutOfOrderMetadataProperties for that. Now set in both AtProtoJsonDefaults.Options and LexiconTypeRegistry.CreateOptions(), fixing getPosts/getPostThread/timeline reads that contain embeds (previously threw NotSupportedException)
    • Directory.Build.props RepositoryUrl dropped the .git suffix so Forgejo's NuGet registry can match the URL against the canonical repo URL on first upload. Without this, every new packable project published as an orphan (resolvable by dotnet add package but absent from the repo's Packages tab in the Forgejo UI), requiring a manual POST /api/v1/packages/Grandiras/nuget/{name}/-/link/ATProto.NET to relink after each first release. Affects new packages only; the five v0.4.0 packages already orphaned (Aspire, Aspire.Hosting, Pds, LexiconGenerator, Server.EntityFrameworkCore) were relinked manually post-release

    Security

    • /.well-known/atproto-did responses are now capped and redirect-checked (Issue #52) — The endpoint lives on a host derived from untrusted user input. Responses are read with HttpCompletionOption.ResponseHeadersRead and capped at 1 KiB (by Content-Length and during the read, so a chunked body can't bypass it) instead of being buffered in full, and a response whose final request URI landed on a different host than the handle is ignored rather than trusted — a hostile handle domain can no longer redirect resolution at an arbitrary host and have that host's DID accepted. Same-host redirects (http→https, trailing slash) still resolve
    • Handle resolution now queries DNS-over-HTTPS on every attempt (Issue #52) — Because ResolveHandleToDidAsync races the two lookups instead of trying HTTPS first, dns.google is contacted for every handle resolution, not only when the HTTPS well-known lookup fails. Deployments that treat the handle being resolved as sensitive should note the additional third-party disclosure. ResolveHandleAuthoritativeAsync already queried both on every call
    Downloads