-
v0.5.0
Stablereleased this
2026-07-25 22:43:20 +00:00 | 19 commits to main since this releaseBreaking changes
ATProtoNet.Pdspackage removed — The in-process PDS implementation is gone; this project does not maintain a PDS. UseATProtoNet.Aspire.Hostingto run the official Bluesky PDS container (ghcr.io/bluesky-social/pds) and the newPdsAdminClientto administer it — see Added below anddocs/managed-pds.md. There is no in-process replacement forAddAtProtoPds()/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 5ATProtoNet.Aspire.Hostingnow targets Aspire 13 — Package reference moved fromAspire.Hosting9.5.2 to 13.4.6. AppHosts referencing it must be on Aspire 13AtProtoPdsContainerResourceconstructor gained threeParameterResourcearguments —(name, adminPassword, jwtSecret, plcRotationKey). Source- and binary-breaking for anyone constructing the resource directly;AddAtProtoPds()is unaffectedATProtoNet.Server.EntityFrameworkCorepackage merged intoATProtoNet.Server(Issue #33) — The EF Core-backedIAtProtoTokenStorenow ships insideATProtoNet.Server. Remove theATProtoNet.Server.EntityFrameworkCore<PackageReference>(it is replaced byATProtoNet.Server); theATProtoNet.Server.EntityFrameworkCorenamespace,AddAtProtoEfCoreTokenStore<TContext>(),AtProtoTokenDbContext, andAtProtoTokenEntityare unchanged, so only the package reference changes.ATProtoNet.Servernow transitively depends onMicrosoft.EntityFrameworkCore.RelationalATProtoNet.Aspirepackage merged intoATProtoNet.Server(Issue #33) — The .NET Aspire client integration now ships insideATProtoNet.Server. Remove theATProtoNet.Aspire<PackageReference>(it is replaced byATProtoNet.Server); theATProtoNet.Aspirenamespace,AddAtProtoClient(...),AtProtoClientSettings, andAtProtoPdsHealthCheckare unchanged.ATProtoNet.Servernow depends onMicrosoft.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(coreATProtoNet, namespaceATProtoNet.Admin) — administers any PDS you hold the admin password for, authenticating with HTTP Basic as the reference PDS expects.CreateAccountAsyncis the headline: it callsdescribeServer, 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;AdminandServerexpose the raw admin-authenticated clients for everything else. The constructor rejects a non-loopbackhttp://URL rather than sending the admin password in the clearPdsAdminOptions.AllowInsecureHttp— opt-in for reaching the PDS over plaintext HTTP at a non-loopback host, the shape an Aspire container network produces. Defaultfalse; the guard validates the effective base address, so supplying anHttpClientwhoseBaseAddressdiffers from the configured URL cannot slip past itAddAtProtoPdsAdmin()(ATProtoNet.Server) — registersPdsAdminClientas a typedHttpClient, bindingAtProto:Pds:Url,AtProto:Pds:AdminPassword, andAtProto:Pds:AllowInsecureHttp(the keys the Aspire integration supplies). Overloads take explicit credentials or aPdsAdminOptions. 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 capturedHttpClientmeans the factory rotates the underlying handler, so a long-running deployment picks up DNS changes behind the PDS URLWithAtProtoPds(pds)(ATProtoNet.Aspire.Hosting) — wires a project to the PDS container in one call:WithReference, the configuration keys above, andWaitForon the container's health check. In run mode it also setsAtProto__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 decisionWithHandleDomains,WithInviteCodeRequired,WithAdminPassword,WithJwtSecret,WithPlcRotationKey,WithDataBindMount— new configuration methods on the PDS resource.AdminPasswordParameter,JwtSecretParameter, andPlcRotationKeyParameterare exposed onAtProtoPdsContainerResource- The container now gets an HTTP health check on
/xrpc/_health, soWaitFor(pds)works, andPDS_HOSTNAMEdefaults tolocalhostso 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 setWithHostnamegained aParameterResourceoverload, and the PDS hostname is now resolved through the resource like the other settings. Running locally it defaults tolocalhost; when publishing there is no sensible default, soAddAtProtoPdscreates a{name}-hostnameparameter for the deployment to supply. The hostname fixes the server'sdid:webidentity and the domain new handles are created under, so a PDS deployed aslocalhostwould issue identities nothing can resolvePDS_DEV_MODE=trueis 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 model —
WithHostname,WithJwtSecret,WithPlcRotationKey, andWithAdminPasswordcreate 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-integrationjob starts the realghcr.io/bluesky-social/pdscontainer as a service and runsPdsAdminTests(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 withAspireManifestTests(gated byATPROTO_ASPIRE_MANIFEST), covering the publish path that needs no container runtime. Both jobs setATPROTO_REQUIRE_INTEGRATION=1, which turns a skipped gate into a failure:dotnet test --filterexits 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.mdand samplessamples/ManagedPdsSample(a signup API built onPdsAdminClient) andsamples/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 toCarReader.Write(root, blocks)for a block dictionary keyed by base32 CID (the shapeMerkleSearchTree.Serialize()returns) or an explicitCarBlocksequence, plusWriteTo/WriteToAsyncfor streamingRepoCommit/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, soFirehoseVerifier.ExtractSignedViewrecovers them intact — that round-trip is asserted in the testsPlcOperationBuilder(Issue #40) — builds, signs and derives DIDs fromdid:plcgenesis operations, withPlcClient.SubmitOperationAsyncto publish them. AddsPlcErrorKind.InvalidOperationMerkleSearchTree.SerializeProof(keys)(Issue #40) — serializes only the root and the nodes on the root→key search paths, the covering proof a firehose#commitor acom.atproto.sync.getRecordresponse carries.Serialize()(the whole tree) is unchangedTid.FromInt64(long)andTid.ToInt64()(Issue #40) — convert between a TID and its raw 64-bit value, so callers needing a strictly increasing sequence can mint one themselvesCidComputation.TryDecodeCidString(Issue #40) — non-throwing CID string decodingDidDocument.Context(Issue #40) — the@contextfield, omitted when serializing unless set. Required when publishing a document (adid: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/subscribeendpoint withwantedCollections(NSIDs or prefix wildcards, max 100),wantedDids(max 10,000),cursor(unix microseconds), andmaxMessageSizeBytessupportJetstreamConsumer— managed consumer with automatic reconnection (backoff,MaxReconnectAttempts), cursor persistence through the existingIFirehoseCursorStore(cursor =time_us), reconnect rewind (ReconnectRewind, default 5 s) with duplicate suppression, and at-least-once delivery semantics across restartsJetstreamEventParser— forward-tolerant parser forcommit/identity/accountevent kinds; unknown kinds, operations, and fields are skipped instead of throwingJetstreamCommitEvent.GetRecord<T>()— typed record deserialization honouringLexiconTypeRegistryregistrations; computedUri(at://did/collection/rkey)IJetstreamDecompressor— optional zstd seam; the SDK ships no zstd dependency,docs/jetstream.mdincludes a copy-pasteZstdSharp.Portimplementation- 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.mdincl. Jetstream-vs-firehose comparison table
AuthorizationServerDiscovery.HandleResolutionTimeout(Issue #52) — Per-round budget for handle resolution, enforced by aCancellationTokenSourcelinked to the caller's token. Default 5 s (AuthorizationServerDiscovery.DefaultHandleResolutionTimeout);Timeout.InfiniteTimeSpanrestores the old unbounded behaviour. Configurable through the newOAuthOptions.HandleResolutionTimeoutandAtProtoOAuthServerOptions.HandleResolutionTimeoutAtProtoOAuthServerOptions.HttpClient(Issue #52) — Lets a consuming app supply theHttpClientused for OAuth discovery and token requests (e.g. anIHttpClientFactoryclient, a proxy, custom handlers). The supplied client'sTimeoutis left untouched and it is not disposed with the serviceAtProtoOAuthServerOptions.HttpClientTimeout(Issue #52) — Timeout applied to the SDK-created OAuthHttpClient. Default 30 sOAuthClientMetadata.ToJson(bool writeIndented = false)(Issue #41) — Renders the client-metadata document exactly as it must be served at theclient_idURL, with unset optional fields omitted.app.MapGet("/client-metadata.json", () => Results.Content(metadata.ToJson(), "application/json"))AtProtoJsonDefaults.ApplyRecordTypeDiscriminator(JsonTypeInfo)(Issue #49) — PublicJsonTypeInfocontract modifier that guaranteesAtProtoRecord-derived types serialize their Lexicon type as exactly one$typeproperty. Applied automatically by the SDK's own serializer options; expose it to hand-builtJsonSerializerOptionsviaDefaultJsonTypeInfoResolver.Modifiers- Typed unions, nested inline objects, and token families in
atproto-lexgen csharp(Issue #45)- A Lexicon union whose variants are all
objectdefs in the generation run now emits anabstract class <Property>Unionwith[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]and one[JsonDerivedType]per variant, and the variants subclass it — soattributionround-trips asAttributionWebsiteinstead of a rawJsonElement. Unions whose variants another union already claimed still fall back toJsonElement?(a C# class has one base) and say so as aWARN - A union of tokens is typed as
string— the value on the wire is the token NSID - Inline
objectschemas 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 anAlllist (CookingMethod.Baking,Diet.Vegan) instead of one static class per token — the recipe.exchangedefsdocument drops from 101 generated classes to 9CSharpEmitter.Warningsexposes the diagnostics;atproto-lexgen csharpprints them asWARNlines
- A Lexicon union whose variants are all
Removed
ATProtoNet.PdsNuGet package,samples/PdsSample, anddocs/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 withATProtoNet.Server.EntityFrameworkCoreandATProtoNet.AspireNuGet packages (Issue #33) — Consolidated intoATProtoNet.Server, cutting the published set from 8 packages to 6. See Breaking changes above for the (reference-only) migration
Fixed
WithDataBindMountproduced a container that could not start —AddAtProtoPds()always mounts a named volume at/pds, andWithDataBindMountadded 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 documentedAddAtProtoPds("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 started —
AddAtProtoPds()setPDS_DATA_DIRECTORYbut no blobstore, and the reference PDS exits during startup withMust configure either S3 or disk blobstore. It now setsPDS_BLOBSTORE_DISK_LOCATIONto/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
AdminClientmethods threwJsonExceptionagainst a real PDS —DeleteAccountAsync,UpdateAccountHandleAsync,UpdateAccountEmailAsync,UpdateAccountPasswordAsync,DisableAccountInvitesAsync,EnableAccountInvitesAsync, andDisableInviteCodesAsyncrequested a deserialized response (ProcedureAsync<TRequest, object>) from endpoints the reference PDS answers with an empty body, so every one of them failed withThe input does not contain any JSON tokensno matter what the server did. They now use the body-onlyProcedureAsync<TRequest>overload. This also affected the correspondingPdsAdminClientwrappers. 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 run —
AddAtProtoPds()generated both withRandomNumberGeneratorwhile 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 withdid:plcidentities 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 withWithJwtSecret/WithPlcRotationKeyfor 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'sgenerateinstruction 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
JsonExceptionon every call (Issue #69) — 19 methods acrossIdentityClient,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 withThe input does not contain any JSON tokensbefore 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, andUpdateHandleAsyncare ordinary client calls. All now use the body-onlyProcedureAsync<TRequest>overload that already existed. No public signatures change, since each of these methods already returnedTaskand discarded the value- The defect survived because the test doubles returned
{}, which deserializes perfectly well.EmptyResponseBodyTestsdrives all 19 NSIDs through a handler that answers 200 with an empty body, the way a real server does
- The defect survived because the test doubles returned
LoginFormthrew when the app had not calledservices.AddLocalization()(Issue #35) — TheIStringLocalizer<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 threwInvalidOperationException: 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. Theservices.AddLocalization()` workaround remains validatproto-lexgen csharpemitted C# that did not compile (Issue #45) — Found by generating the publishedexchange.recipe.*Lexicons (recipe.exchange) for a third-party appview. All of the following are fixed, with unit coverage inCSharpEmitterTests:- Stray closing brace — every generated file ended with an extra
}after the file-scoped namespace, so nothing compiled - CS0542 member/type name collisions — a
blobpropertyimageinside theimagedef generatedBlobRef Imageinsideclass Image. Members that collide with their enclosing type (or with another member, or withAtProtoRecord'sType/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
#appPassworddefs undercom.atproto.servercollided; later defs are now prefixed with their document name and the rename is reported - Unqualified
BlobRef/SDK types — generated files now emitusing ATProtoNet;/using ATProtoNet.Models;only when needed, plus#nullable enableandusing System.Collections.Generic;. Cross-namespace references are rooted atglobal::so a generated namespace such asBsky.Generated.Chat.Bsky.Actorno longer shadows the reference - Non-nullable
JsonElementfallbacks — optional members are always nullable; serializingdefault(JsonElement)(ValueKind.Undefined) threw - Cross-namespace refs landed in the consumer's namespace —
app.bsky.embed.defs#aspectRatiogenerated<Prefix>.App.Bsky.Embed.AspectRatio(a type that does not exist) instead of the SDK'sATProtoNet.Lexicon.App.Bsky.Embed.AspectRatio. Well-knowncom.atproto.*/app.bsky.*defs now map to the SDK's own models (SdkTypeMap); refs that resolve to nothing fall back toJsonElement?and are reported asWARNrather than emitting a dangling type name recorddefs did not extendAtProtoRecord— they re-declared$type/createdAtand missed the SDK's record ergonomics (GetCollection<T>()). They now subclassAtProtoRecord, overrideType, and inheritCreatedAtatprotoNSID segment cased asAtproto— generated namespaces/folders now readCom.AtProto.*, matching the SDK layout andNsidToNamespace's documented behaviour- Lexicon
"type": "number"(used in real-world schemas though absent from the spec) maps todoubleinstead ofJsonElement
- Stray closing brace — every generated file ended with an extra
[JsonPropertyName("$type")]is now repeated on everyTypeoverride (Issue #45) —System.Text.Jsondoes not carry the attribute from the abstractAtProtoRecord.Typeonto an override, so records serialized with hand-builtJsonSerializerOptions(i.e. without the Issue #49 contract modifier below) emitted a spurious"type"field. The attribute is now emitted byatproto-lexgen csharpand repeated in theRecordCollection/README/docsexamples and the test fixtures, so the documented pattern is correct under any serializer options;RecordCollectionTestslocks the behaviour inOAuthClientMetadataserialized unset optional fields as JSONnull, so authorization servers rejected the client-metadata document (Issue #41) — The AT Protocol OAuth spec distinguishes absent from null, and the reference@atproto/oauth-providerfails a document containing"jwks_uri": null/"logo_uri": null/"token_endpoint_auth_signing_alg": nullwithinvalid_client_metadata, breaking PAR for any app that servedResults.Json(metadata)at itsclient_idURL. Every optional property onOAuthClientMetadata(client_name,client_uri,logo_uri,tos_uri,policy_uri,token_endpoint_auth_signing_alg,jwks,jwks_uri) and on the nestedJsonWebKey(crv,x,y,kid,use,alg) now carries[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)], so the document is spec-compliant under anyJsonSerializerOptions— including the ASP.NET Core defaults — with no consumer change. TheDefaultIgnoreCondition = WhenWritingNullworkaround remains validAtProtoRecordsubclasses serialized a straytypeproperty alongside$type(Issue #49) —AtProtoRecord.Typeis abstract and carries[JsonPropertyName("$type")]on the base member. System.Text.Json neither inherits that attribute through anoverridenor 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 everyAtProtoRecord-derived type. It is wired intoAtProtoJsonDefaults.OptionsandLexiconTypeRegistry.CreateOptions(), soRecordCollection<T>andRepoClientwrites are fixed with no consumer change; the workaround of re-declaring[JsonPropertyName("$type")]on the override remains safe. Add the modifier to your ownDefaultJsonTypeInfoResolver.Modifiersif you serialize records with hand-builtJsonSerializerOptions- Implementing
IAtProtoUnionbroke all (de)serialization of records containing the union (Issue #46) — The internalUnionJsonConverterFactoryregistered inAtProtoJsonDefaults.OptionsandLexiconTypeRegistry.CreateOptions()claimed every type assignable toIAtProtoUnionfromCanConvertbut returnednullfromCreateConverter, which System.Text.Json rejects withInvalidOperationException: 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]plusLexiconTypeRegistry.RegisterUnionVariant— so it has been removed.IAtProtoUnionremains as a behaviour-free documentation marker;docs/custom-records.mdgains a "Union Types" section covering closed and open unions PlcClientDID-path requests bypassed the directoryBaseAddress(Issue #47) —did:plc:…strings parse as absolute URIs (schemedid), soResolveDidAsync,GetOperationLogAsync,GetAuditLogAsync,GetLastOperationAsync, andGetPlcDataAsyncall failed withNotSupportedException: The 'did' scheme is not supportedinstead of queryinghttps://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
HttpClientdefault before the flow continued, so sign-in appeared to hang.ResolveHandleToDidAsyncnow 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 andResolveHandleAuthoritativeAsyncbound each round withHandleResolutionTimeout(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
HttpClientno longer inherits the 100 s default timeout (Issue #52) —AtProtoOAuthServicenow appliesAtProtoOAuthServerOptions.HttpClientTimeout(30 s) to the client it creates, and only overwrites theUser-Agentheader 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-configuredHttpClientto shorten it - A timed-out handle probe no longer aborts OAuth sign-in (Issue #42) — Handle verification in
CompleteAuthorizationAsyncis best-effort: it distinguished failures by exception type, so a refused connection (HttpRequestException) leftIsHandleVerified = falsewhile a timed-out probe (TaskCanceledException— a parked handle domain, or any host when theHttpClienthas aConnectTimeout) propagated out and failed the whole login, even though the authoritative DID from the token response'ssubwas already in hand. Both now yield an unverified handle; only the caller's ownCancellationTokenstill aborts the flow (and now disposes the pending DPoP key when it does).VerifyDidToAuthServerConsistencyAsynclikewise reports caller cancellation as cancellation rather than wrapping it in anauth_server_mismatch-styleOAuthException; probe timeouts there still fail closed - Polymorphic payloads with a non-leading
$typefailed to deserialize (Issue #50) — The Bluesky appview serializes embed views (and real-world writers serialize record-internal unions) with the$typediscriminator anywhere in the object, not necessarily first; System.Text.Json requiresAllowOutOfOrderMetadataPropertiesfor that. Now set in bothAtProtoJsonDefaults.OptionsandLexiconTypeRegistry.CreateOptions(), fixinggetPosts/getPostThread/timeline reads that contain embeds (previously threwNotSupportedException) Directory.Build.propsRepositoryUrldropped the.gitsuffix 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 bydotnet add packagebut absent from the repo's Packages tab in the Forgejo UI), requiring a manualPOST /api/v1/packages/Grandiras/nuget/{name}/-/link/ATProto.NETto 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-didresponses are now capped and redirect-checked (Issue #52) — The endpoint lives on a host derived from untrusted user input. Responses are read withHttpCompletionOption.ResponseHeadersReadand capped at 1 KiB (byContent-Lengthand 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
ResolveHandleToDidAsyncraces the two lookups instead of trying HTTPS first,dns.googleis 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.ResolveHandleAuthoritativeAsyncalready queried both on every call
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads