-
v0.6.0
Stablereleased this
2026-08-21 14:07:52 +00:00 | 0 commits to main since this releaseBreaking changes
AtProtoScopes.Repo(...)now throwsArgumentExceptionforRepoAction.None(Issue #94) — the scope grammar has no marker for an empty action list, soRepoAction.Nonepreviously emittedrepo:<nsid>: a full create/update/delete grant, the opposite of what the caller asked for. Migration: drop therepo:scope entirely if no record writes are needed, or name the narrowest action that is (Create,Update,Delete).RepoAction.Alland partial combinations are unaffected
Added
-
Spaces: the permissioned data protocol (Issue #89) — client-side support for permissioned data (proposal 0016): the familiar AT Protocol shape — DID authority, per-user repos, Lexicon-typed records — behind an access perimeter called a space. This is an alpha proposal with no security review, and it provides access control, not confidentiality: the data is not end-to-end encrypted and every service handling it can read it
SpaceUri/SpaceRecordUri—at://{authority}/space/{type}/{skey}[/{author}/{collection}/{rkey}]. Authority splits in two: the URI's authority gates access, the record's author DID signed it. Neither may be a handleLtHash— the homomorphic set hash a permissioned repo commits to in place of an MST root. Order-independent, so a write is one cheap pass over the lanes; verified lane-for-lane against the reference implementation- BLAKE3 in XOF mode, implemented from scratch since .NET ships none and the SDK carries no third-party cryptography. Checked against 27 reference test vectors
SpaceRepoCommit/SignedSpaceCommit/SpaceCommitVerifier— deliberately not a rebroadcastable proof: the signature covers only the commit context, and the digest is bound to it by a symmetric MAC, so a leaked commit proves nothing to a third party. Context encoding and MAC match the reference implementation byte-for-byteSpaceRepoCar— the two-root CAR formgetReposerves (signed commit, then a DAG-CBOR path→CID index, then the record blocks).Verifyauthenticates the whole thing in one pass;excludeValuesyields an index-only CAR for diffing against a local copySpaceCredentialProvider/SpaceReader/SpaceTokens— the credential exchange over two independent axes: which user (a delegation token from their PDS) and which application (a self-signed client attestation). A credential is DPoP-bound rather than a bearer token, since it reads a whole space and is presented to every host in it; cached per space and renewed ahead of expirySpaceSyncer/SpaceRepoCursor/ISpaceRepoStore— there is no relay for permissioned data, so an application pulls from each repo host directly. Comparing digests rather than tracking individual operations is what makes sync self-healing: dropped writes, compacted oplogs, and corrupted copies all surface as a mismatch and repair by full downloadcom.atproto.space.*onAtProtoClient.Spaceandcom.atproto.simplespace.*onAtProtoClient.SimpleSpace— the full endpoint surface, plus the baseline space-management implementation every PDS must support.listReposreturns the writer set (accounts that have written), which is the sync boundary and not an access-control list; readers are never enumerated at the protocol levelAtProtoScopes.Space(...)withSpaceAction/SpaceManage—space:OAuth scopes granted by space type,authoritydefaulting toself.Readalso confersgetDelegationTokenand therefore the whole space, whileReadSelfreaches only the holder's own repo — the right grant for an export toolSpaceAuthority(resolves#atproto_space/#atproto_space_hostwith fallbacks, so any ordinary account works as an authority),SpaceTypeDeclaration, andLexBytesJsonConverterfor the{"$bytes": "…"}wrapperdocs/spaces.md,samples/SpacesSample, and 212 unit tests pinning every cryptographic construction against the reference implementation's own outputs
-
Spaces: server-side support in
ATProtoNet.Server(Issue #91) — the other half of #89: the ASP.NET Core layer that lets a .NET service act as a space authority, a repo host, or both. Registered withAddAtProtoSpaces()plusAddSpaceAuthority<T>(key)/AddSimpleSpace<T>()/AddSpaceRepoHost<T>(), mapped by the ordinaryMapXrpcEndpoints(). The three register separately so a service that only needs to verify takesAddAtProtoSpaces()aloneDPoPProofValidator— six checks: signature against the proof's ownjwk, that key's thumbprint against the credential'scnf.jkt,athagainst the credential presented,htm/htuagainst the request as received, a recentiat, and an unseenjti.htuis compared with query and fragment stripped per RFC 9449 §4.3; a non-absolutehtuis refused rather than string-compared, andalgis pinned to the key's curveSpaceDelegationTokenVerifier—audmust equalspaceHostAud(spaceDid)for the authority in the token's ownsub, so a token minted for another authority cannot be presented here. Thejtiis consumed last, only after every other check passesSpaceCredentialVerifier— the signer is resolved from the space URI, not the credential'siss, so only a space's own authority can mint credentials for itSpaceClientAttestationVerifier— resolvesclient_idto itsclient-metadata.json, followsjwks_uri, and verifies against the key thekidnames (trying every published key would let one compromised key be laundered through another). The fetch ishttps-only and bounded byMaxClientMetadataBytesISpaceReplayStore— single-use enforcement keyed on(iss, jti, exp), with an in-process default. Replace it in a multi-instance deployment, or a replay is caught only by the instance that saw the originalSpaceServerOptions.MaxSingleUseTokenLifetime(default five minutes) caps how far ahead an inbound single-use token'sexpmay sit, bounding both replay window and replay-store occupancy.SpaceServerOptions.PublicBaseUrlis not optional behind a reverse proxy, since DPoPhtuis compared against the request as received- The endpoint surface:
getSpaceCredential,listRepos,registerNotify,unregisterNotify,notifyWrite(authority side);getRecord,listRecords,getLatestCommit,getRepo,listRepoOps,getBlob,listBlobs(repo side, over anISpaceRepoHost).RepoNotFounddeliberately does not distinguish a silent member from a non-member — saying more would leak membership com.atproto.simplespace.*— all seven administration methods over anISimpleSpaceStore, plusSimpleSpaceAccessPolicy: a user perimeter (MemberListPolicy/PublicPolicy/ManagingAppPolicy) and an app perimeter (OpenAppAccess/AllowListAppAccess, evaluated against the attested client ID). AManagingAppPolicywhose app is unreachable refuses, since failing open would turn an outage into an open spaceSpaceWriteNotifier— fansnotifyWrite/notifySpaceDeletedout with service auth, best-effort by design because the syncer'slistRepossweep is the correctness guarantee.EnsureAuthoritySubscribedAsyncauto-registers a space's authority on first write, which is what populates the writer set- Key resolution reads every verification-method type the SDK understands (see #98), so an authority is not accepted or refused based on whether a token carried a
kid - 105 new unit tests including a
TestServerend-to-end pass;docs/spaces.mdgains a Serving a space section
-
Spaces: durable and multi-instance implementations of the space server stores (Issue #102) — #91 shipped all three stores in-memory, which is right for a test host and wrong for anything else
RedisSpaceReplayStore— the replay store was a correctness gap across instances, not a durability one: two replicas behind a load balancer each accepted the same delegation token. Consuming a token is one atomicSET key value NX EX ttl, with the entry's TTL being the token's own remaining lifetime. Registered withAddAtProtoRedisSpaceReplayStore(); adds aStackExchange.Redisdependency toATProtoNet.ServerEfCoreSimpleSpaceStore<TContext>— a member list is the one piece of space state that cannot be rebuilt, so a restart that lost it lost the space's access control. Policy unions are stored as their Lexicon JSON, so a new variant needs no schema changeEfCoreSpaceAuthorityStore<TContext>— writer set and notification registrations, withDeclareSpaceAsync/MarkDeletedAsync. Pagination is by DID and evaluated by the database, so ordering and cursor comparison agree under any collationEfCoreSpaceReplayStore<TContext>— for deployments with no Redis; the primary key is the replay check. A failed save is confirmed against the table before being reported as a replay, so a storage fault does not masquerade as one. Expired rows are swept opportunistically, at most once a minute- All four take an
IDbContextFactory<TContext>and sit inATProtoNet.Server.EntityFrameworkCorealongside the token store. UseSpaceDbContextor callSpaceDbContext.ConfigureSpaceModel()from your own context AddAtProtoSpaces()now warns at startup while the replay andsimplespacestores are in-process defaults; suppress withSpaceServerOptions.WarnOnInMemoryStores = false
-
Spaces: integration tests against a real permissioned-data PDS (Issue #93) — #89's 212 unit tests all stubbed the HTTP layer, proving the SDK agrees with a reading of the spec, not that a server accepts what it sends. 26 tests in
tests/ATProtoNet.IntegrationTests/now talk to a live space host behind a[RequiresSpacesFact]gate (ATPROTO_TEST_SPACES=true), so CI is unaffectedSpaceNetworkFixtureprovisions three accounts (authority, member, outsider) through the admin API, since a space is a three-party arrangement a stub cannot tell apart.ATPROTO_PLC_URLpoints DID resolution at the test network's own directorySpaceCredentialTests— the two-hop exchange end to end, then the refusals: replayed token, wrong space, wrong key, wrong host, credential presented as a bearer token, andSpaceDeletedon renewalSpaceRepoSyncTests— the CAR round trip verified against a real server's own commit and index, plus incremental sync, cursor resumption, divergence detection, and full-recovery fallbackSimpleSpacePolicyTests— non-member refusal,#allowListrefusal, attestation retry, revocation at renewal, and the repo boundary between two accounts on one hostdocs/testing-spaces.mdcovers standing a host up. No PDS release servescom.atproto.space.*yet — it lives on bluesky-social/atproto#5187, which these tests were run against
-
atproto-lexgenunderstands"type": "space"Lexicon definitions (Issue #92) — previously a space-type Lexicon produced an empty file and no diagnostic- JSON → C#: a
spacedefinition emits a static holder (com.atmoboards.forum→ForumSpace) exposingNsid, aSpaceTypeDeclaration Declaration, andKey/Name/LocalizedNames/Collectionsforwarders. A declaration missing a required field still emits compiling code and says what it substituted - C# → JSON:
atproto-lexgen lexiconemits aspacedefinition for every staticSpaceTypeDeclaration, taking the NSID from a siblingNsidconstant, so declarations round-trip. Ambiguous or unattributable declarations are reported through the newLexiconEmitter.Warnings - Diffing:
atproto-lexgen diffcompares declarations. Because a barespace:grant resolves its collection set when the grant is evaluated, adding a collection widens every existing grant (reported) and removing one narrows them (breaking); key changes are breaking,namechanges are not - An unrecognized definition type is now a
WARNnaming the NSID and type instead of an empty file
- JSON → C#: a
-
XRPC handler routing: named errors and binary responses (Issue #91) —
XrpcException(error, message, statusCode)thrown from a handler is written as the{"error", "message"}body XRPC clients branch on rather than escaping as a 500, and carries aHeadersdictionary for things likeWWW-Authenticate.IXrpcBlobQuery<TParams>is the counterpart ofIXrpcQuery<,>for non-JSON output encodings (getBlob,getRepo, the CAR methods), streaming rather than buffering. Query-parameter binding failures now answerInvalidRequest, andXrpcBooleanConverterbinds Lexiconbooleanparameters -
Jetstream v2 is now supported alongside v1 (Issue #83) — the second wire protocol (atproto proposal 0015) serves at
/xrpc/network.bsky.jetstream.subscribeEventsand differs from v1 in nearly every particular: a self-describing envelope, flat commit fields,collections/dids/kindsfilters, a sequence-number cursor, asyncevent kind, and out-of-band#info/errorframes.JetstreamClientandJetstreamConsumerspeak both, selected byJetstreamConsumerOptions.ProtocolProtocoldefaults toJetstreamProtocol.V1, so existing configurations are unchanged.JetstreamEndpointsnames the hosts:UsEast/UsWest(v2) andLegacyUsEast1/LegacyUsEast2/LegacyUsWest1/LegacyUsWest2WantedKinds— the v2kindsfilter. A collection filter constrains commit events only, so a commits-only stream needsWantedKinds = [JetstreamEventKind.Commit]; combiningWantedCollectionswith aWantedKindsthat excludesCommitnow throws before the socket opensJetstreamSyncEvent— a repo resynchronization marker (v2 only); handle it as you would an account deletionJetstreamEvent.Cursor— the sequence number and v2 resume position, unaffected by operator timestamp imports.JetstreamEvent.TimestampexposesTimeUsas aDateTimeOffset- Cursor handling follows the protocol: v2 cursors replay inclusively, so
ReconnectRewindis ignored, and an event with no sequence number is not persisted - A rejected subscription is no longer retried in a loop — v2 validates before the WebSocket upgrade, and
JetstreamConnectException(withStatusCode/IsRetryable) surfacesCursorTooOld,UnknownZstdDictionary, and malformed filters. The consumer persists progress and rethrows rather than silently skipping the gap OnInfo/OnStreamErrorfor v2's advisory and terminal frames;JetstreamDictionaryClientfetches the versioned zstd dictionary and reads its ID from the dictionary's own header. Setting only one ofZstdDictionaryId/Decompressornow throwsJetstreamEventParser.ParseFrame(json, protocol)returns aJetstreamFrame; the existingParse(...)overloads still read v1. Both wires stay forward-tolerantJetstreamV2Testschecks the protocol against a live instance, gated by[RequiresJetstreamFact](ATPROTO_TEST_JETSTREAM=true); needs no PDS and no credentials. Documented indocs/jetstream.md
-
Jetstream v2 archive: replay and snapshot (Issue #85) — the HTTP archive alongside the live tail, so an indexer gets the records that already exist and every new one with no gap at the seam.
JetstreamReplayConsumer.ReplayAsync()backfills history and cuts over into the live tail in a singleawait foreachJetstreamSegmentReader— decoder for the sealed segment format (.jss): fixed header, length-prefixed zstd frames, columnar block body.ReadRowsAsync/ReadEventsAsyncstream block by block rather than materializing segments that run to hundreds of megabytes.JetstreamArchiveRowexposes the untouched CBOR so a mirror stays byte-auditable;ToEvent()projects to the live tail'sJetstreamEvent. Verified against Jetstream's own golden fixturesJetstreamArchiveClient— typed wrappers forplanSnapshot,listSegments,getSegment,getBlockwith bearer auth,Range, and ETags. The endpoints are metered in response bytes, so a429waits out exactly itsRetry-AfterandDownloadSegmentAsyncresumes from the byte offset it stopped at.JetstreamArchiveExceptioncarriesStatusCode/Error/RetryAfter/IsRetryable, so a revoked key is not retried- The plan loop pins the tip: the first
planSnapshot'ssealedTipSeqis the ceiling for the whole backfill, so the range cannot float mid-download. A page that fails to advance while the ceiling is ahead is re-planned with backoffMaxStalledPlanAttemptstimes (default 5) and then fails, rather than leaving a permanent silent gap. Downloads runDownloadParallelismdeep but decode in plan order; since the planner works from bloom filters, the exact filters are re-applied to what was decoded - The cutover is inclusive and deduplicated. If the backfill outran the socket's 36-hour lookback, the refused connect sends the consumer back into the plan loop (up to
MaxCutoverAttempts) rather than skipping the gap JetstreamConsumerOptions.Archive(newJetstreamArchiveOptions) configures it all; filters andCursorStoreare shared with the live tail, so one store spans both phases and a restart resumes the backfillIJetstreamBlockDecompressoris a separate seam fromIJetstreamDecompressoron purpose: segment blocks carry no dictionary. The SDK still bundles no zstd. Segment checksums are exposed rather than recomputed (they are xxh3 metadata checksums, and a compaction rewrites them)JetstreamArchiveTestsgated by[RequiresJetstreamArchiveFact](ATPROTO_JETSTREAM_API_KEY), deliberately small since the endpoints bill real bytes. Newsamples/JetstreamReplaySample
-
JetstreamEventParser.ParseFrame(ReadOnlyMemory<byte>, JetstreamProtocol)(Issue #87) — a no-copy counterpart to the span overload for callers already holding the frame on the heap.JsonDocumentcannot parse a span without copying it; the memory overload reads in place, taking ~600 bytes per event offJetstreamClient's receive loop -
Tranquil PDS is now supported alongside the reference Bluesky PDS (Issue #78) — Tranquil is a community PDS (single Rust binary; passkeys, 2FA, SSO,
did:webaccounts, granular OAuth scopes, a web UI) and a superset of the reference serverAddAtProtoTranquilPds(name, port?, tag?)(ATProtoNet.Aspire.Hosting) — adds the container plus a{name}-postgresserver and{name}-dbdatabase, since Tranquil keeps repositories in PostgreSQL.WithDatabase(database)/WithDatabaseUrl(url)point it at an existing one and drop the generated resources. Tranquil is handed apostgres://URI, not an ADO.NET connection stringWithAtProtoTranquilPds(pds)wires a project to the container exactly asWithAtProtoPdsdoes for the reference server- Configuration methods on
AtProtoTranquilPdsContainerResource:WithAdminAccount,WithDevelopmentMode,WithHostname,WithHandleDomains,WithJwtSecret,WithDPoPSecret,WithMasterKey,WithPlcRecoveryKey,WithBlobVolume,WithBlobBindMount,WithS3BlobStorage,WithPlcUrl,WithCrawlers,WithReportService,WithBlobUploadLimit,WithInviteCodeRequired,WithEmail.WithPlcRecoveryKeytakes a publicdid:keyrather than the reference server's hex private key, because Tranquil adds it only to the rotation keys PdsAdminClientcan authenticate as an administrator account, not just with a server-wide admin password:PdsAdminOptions.Authentication(newPdsAdminAuthenticationenum, defaultAdminPassword) andPdsAdminOptions.AdminIdentifier. UnderAdminAccountthe client signs in lazily on first use, reuses the session, and re-authenticates once if the server later rejects it. NewEnsureAdminSessionAsync()does the same for callers using the rawAdmin/ServerclientsAddAtProtoPdsAdmin()binds the two new configuration keys and fails at host build time whenAdminAccountis selected without an identifier- The administrator account is not created for you, by design — Tranquil flags the first account on an empty instance as admin, so the application creates it once with
PdsAdminClient.CreateAccountAsync. The handle defaults topdsadmin.{hostname}(notadmin., which Tranquil reserves) - Running locally the container gets the relaxations a development instance needs (
INVITE_CODE_REQUIRED=false,DISABLE_ACCOUNT_VERIFICATION_GATE=true, rate limiting off,SERVER_HOST=[::]) and none of them when publishing; without them no account could be created or signed in.WithDevelopmentMode(false)turns the set off - Secrets are generated at 48 alphanumeric characters and persisted to user secrets locally; unlike the reference PDS's hex secrets these are shapes an Aspire manifest
generateblock can describe, so a published deployment produces its own - Documented in
docs/managed-pds.md,docs/aspire.md,docs/api-reference.md,docs/architecture.md
-
The core package's public API is now fully XML-documented (Issue #72) —
ATProtoNetemitted 1114 CS1591 warnings, so most of the public surface arrived in IntelliSense with an empty tooltip. All 1114 members now carry a<summary>. Insert-only apart from onecreffix
Changed
-
Performance and memory pass over the streaming, repository, and CID hot paths (Issue #87) — no public behaviour changes. Measured on a 4693-byte
#commitframe, a 1000-entry MST, and a 1000-block CAR, tiered compilation disabled (median of three runs):Operation Time before → after Allocated before → after FirehoseEventParser.Parse(one#commit)147 µs → 22 µs (6.6×) 102 KB → 29 KB (3.5×) MerkleSearchTree.Create(1000 entries)3.44 ms → 0.61 ms (5.7×) 743 KB → 161 KB (4.6×) MerkleSearchTree.Get× 10003.10 ms → 0.10 ms (31×) 362 KB → 0 CarReader.FindBlock× 10006.10 ms → 0.03 ms (187×) 40 KB → 0 CidComputation.EncodeCidToString88 ns → 67 ns (1.3×) 432 B → 144 B (3×) MerkleSearchTree.Serialize(1000 entries)961 µs → 903 µs 1348 KB → 1190 KB (1.13×) FirehoseEventParsertranscodes DAG-CBOR straight into the JSON the models bind from, replacing five passes over every frame (and a deep clone at every nesting level) with one. The CAR blob never becomes a UTF-16 stringCarReader.FindBlockbuilds a deferred CID index on first use instead of scanning every block, which made a repo walk quadraticMerkleSearchTree.Get/TryUpdateno longer callList.IndexOfon the entry they are iterating, andGet/Removeno longer compute an unused SHA-256 per node visited; lookups now allocate nothingMerkleSearchTree.Createhashes each key once instead of once per layer, and partitions on index bounds rather than copying entry ranges into fresh listsCidComputation.EncodeCidToStringbuilds the string in one pass instead of three allocations;CarBlock.CidHexusesConvert.ToHexStringLower- The
FirehoseClient/JetstreamClientWebSocket read loops take a single-receive fast path instead of staging every message through aMemoryStream; multi-frame messages still spool CarReader.FromStreamAsyncparses the buffer it already filled instead of copying the whole CAR onto the large object heap;FirehoseVerifier.ExtractSignedViewwrites into one exact-sized array (WriteMapHeadernow takes aSpan<byte>, alongside a newMapHeaderLength)AtProtoJsonDefaults.Optionsis initialized by the type initializer rather than a racy??=, which could give concurrent first calls their own reflection-derived contract cache. It stays mutable until first useXrpcClientjoins theatproto-accept-labelersheader once when the subscription is set;DagCborEncodercounts arrays without materializing a list and sorts keys in place;Did.Methodslices instead ofSplit(':')
-
Codebase cleanup sweep: 323 net lines removed from
src/, no intended behaviour change beyond the fixes listed below — Lexicon clients build query parameters with a new internalXrpcParamsbuilder at 78 call sites instead of hand-rolled dictionaries (this is what fixes the comma-joining bug);XrpcClient's eight near-identical overloads delegate to oneSendAsyncpair;RecordCollection's*/*Frompairs no longer duplicate bodies;AtProtoClient's four delete-by-AT-URI bodies, two relay-URL guards, and threenew Session { … }blocks collapse to helpers. Dead code removed:XrpcQueryBuilder.BuildQueryString, itsToDictionaryalias,ModerationClient.AddListParams, and an unreachableAdminClientlist -
Session.With(...)added — returns a copy with selected fields replaced, so a token rotation need not restate nine untouched fields. Additive -
AtProtoHttpException.ResponseBodyis nowinit-settable — additive; no existing signature changed -
21 Lexicon client classes no longer take an
ILoggerthey never wrote to. Their constructors areinternal, so this is not a public API change;ServerClientandPdsAdminClient, which do log, are unchanged -
LoginFormdefault copy now says "username" instead of "handle" (Issue #80) — the field label defaults to"Username"and the hint to"Your Atmosphere account username — your PDS is detected automatically.". Same split as Issue #32: the community's word in the copy, the protocol's word in the code. Nothing below the copy changed (HandleLabel/HandlePlaceholder/HandleHint, thehandleparameter, theatproto-handleinput id are all as they were), so this is not source- or binary-breaking. Tests asserting on the old strings must update or pass explicit values -
ATProtoNet.Aspire.Hostingnow depends onAspire.Hosting.PostgreSQL(Issue #78) — Tranquil PDS does not run without PostgreSQL. AppHosts using onlyAddAtProtoPdsare unaffected apart from the extra restore -
CS1591 is now a build error on all four documented packages (Issue #72), so a new undocumented public member fails the build.
TreatWarningsAsErrorsstaysfalse -
All NuGet dependencies updated to their latest stable versions (Issue #73) —
--outdated,--deprecated, and--vulnerable --include-transitiveall come back empty. NotablySystem.Formats.Cbor9.0.4 → 10.0.10, moving the last package off the .NET 9 line, plus theMicrosoft.Extensions.*, EF Core, and resilience packages. No source change was needed -
Test suite migrated from
xunit2.9.3 toxunit.v33.2.2 (Issue #73) —xunit2.x is deprecated on nuget.org with no non-deprecated 2.x release, so a version bump could not clear it. The migration was small: fourIAsyncLifetimeimplementations returnValueTask, and five customFact/Theorysubclasses forward[CallerFilePath]/[CallerLineNumber]. No test was rewritten. xUnit1051 isNoWarned with a comment, since adoptingTestContext.Current.CancellationTokenat ~260 call sites belongs in its own change -
CI actions updated (Issue #73) — the GitHub mirror workflows move to
actions/checkout@v7andactions/setup-dotnet@v6. The Forgejo workflows pin no actions
Fixed
- A space created through
com.atproto.simplespaceis now one its authority answers for (Issue #105) —ISimpleSpaceStoreandISpaceAuthorityStoreheld separate state and nothing bridged them, so a service registered the documented way minted credentials correctly but refused every write notification for its own spaces withSpaceNotFound. The writer set could therefore never be populated, and since it is the sync boundary, no syncer could find anything.deleteSpacehad the mirror gap:listReposkept returning the writer set instead ofSpaceDeleted.AddSpaceAuthority<T>()now wraps the store in the newSimpleSpaceAuthorityStorewhenever anISimpleSpaceStoreis registered, in either order. Existence and deletion are read from the space-management store rather than copied, so there is no second write to keep in step. Spaces thesimplespacestore has never heard of fall through to the inner store SpaceSyncerreportedPartialforever for a member who had never written to the space (Issue #99) — a missing commit was read as "the page stopped short of the head", but an account with no repo state produces one too, so the pass applied nothing, advanced nothing, and reported the outcome documented as "sync again to continue".Partialis now reported only when the pass has somewhere left to go; a page with neither operations nor a cursor isSpaceSyncOutcome.NoRepo. A cursor already standing at a revision takes the existing repair path instead. No signature changed and no enum member was added- DID document signing keys are read from the legacy verification-method types too (Issue #98) — both places the SDK pulled a signing key required
type == "Multikey", so against a document publishingEcdsaSecp256k1VerificationKey2019/EcdsaSecp256r1VerificationKey2019the key came back absent: no permissioned commit could be verified, andFirehoseVerifiertreated every commit from such an account as unverifiable. The encodings differ by more than the type string (multicodec-tagged compressed bytes vs a bare uncompressed point), so the point is now compressed and re-tagged with the curve the type names. plc.directory servesMultikey, so productiondid:plcwas never affected;dev-envnetworks and hand-writtendid:webdocuments publish the legacy form. New public API:VerificationMethod.ToDidKey(),DidDocument.GetSigningKey(),DidDocument.GetVerificationKey(fragment),AtProtoCrypto.FormatDidKey(...),AtProtoCrypto.CompressPublicKey(...) - DAG-CBOR map keys were sorted bytewise rather than length-first — DRISL orders keys by length and only then by bytes, so
{"b":…,"ab":…}must encodebfirst. Every CID the SDK computed for a record whose keys spanned more than one length was therefore wrong — including anyapp.bsky.feed.postcarrying bothtextandcreatedAt— so it would not match the CID the rest of the network computed.DagCborDecoder's matching validation would have rejected valid blocks. Regression-pinned against a post fetched from a live PDS - DPoP proofs named the full request URL in
htu— RFC 9449 §4.2 requires query and fragment stripped, so every proof for a request carrying a query string — which is every XRPC query — named anhtuno conforming resource server would match. Now normalized to origin plus path, which also means one proof covers any query on a path - XRPC array query parameters are now sent as repeated keys instead of one comma-joined value — 21 call sites built
?dids=a,bwhere XRPC specifies?dids=a&dids=b, so any endpoint taking an array silently saw one malformed element. AffectedgetPosts,getFeedGenerators,getProfiles,getServices,getRelationships,getStarterPacks,getAccountInfos,queryLabels,getConvoForMembers,getConvoAvailability,searchAccounts, and the eight array filters onqueryEvents/querySubjects. Callers passing a single element are unaffected RecordCollectionlist operations no longer hand back records whoseValueis null —ListAsync/ListFromAsyncsuppressed a failed deserialization withvalue!, so an entry not matchingTviolated its ownrequiredcontract and threw aNullReferenceExceptiondownstream. They now throwInvalidOperationExceptionnaming the record URI and target type, asGetAsyncalready did- A failed per-request client build no longer leaks an ECDSA key handle —
AtProtoClientFactory.CreateClientForUserAsyncbuilt the client and DPoP session beforeApplyOAuthSessionAsynctook ownership; if that threw, the native key handle survived until finalization. Both are now disposed on the failure path FileAtProtoTokenStorewrites are atomic and deletes are serialized against them —StoreAsyncwrote in place, so a crash mid-write could truncate the file and lose the refresh token; it now writes a temp file and moves it.RemoveAsyncnow takes the same lock as writes, so a logout racing a rotation cannot leave the just-written file behind. Token files are created owner-only on Unix- XRPC error responses that are not an XRPC error envelope now keep their body — the fallback
AtProtoHttpExceptiondiscarded text it had already read, leavingResponseBodynull exactly when it was most useful. The parse failure is also no longer caught with a barecatch (Exception) - Non-integral query parameters are formatted with the invariant culture — a client under a culture such as
de-DEno longer sends1,5where the server expects1.5 Cid.Parse/TryParsedocumentation matches what they do — both were described as validating, and the type carried aGeneratedRegexnothing called. The dead regex is gone and the summaries say plainly that only null/blank input is rejectedAtProtoClientFactoryno longer claims a refresh that does not happen — a comment described on-demand refresh inXrpcClientthat does not exist, so per-request clients silently never refreshed. The comment now states the actual contractREADME.mdanddocs/audited against the actual public API (Issue #74) — every type, method, parameter, and constant named in the documentation was cross-checked againstsrc/andtools/. The larger corrections:docs/crypto.mddocumented anAtProtoKey.Generate/EncodeMultikey/GenerateDidKey/Base58Encodesurface that does not exist, a staticServiceAuthGenerator.CreateToken, and an MST taking string CIDs;docs/standard-site.mdomitted the repository argument everyStandardSiteClientmethod takes and used record fields that were never in the Lexicon models;docs/ozone.mdnamed moderation eventsModerationEvent*instead ofModEvent*. Smaller fixes acrossapi-reference.md,did-resolution.md,firehose.md,identity-types.md,blob-upload.md,video.md,labeler.md,batch-operations.md,server.md,aspnet-core.md,managed-pds.md,oauth.md, andlexicon-codegen.md- Documentation added for the 0.5.0 repository-authoring APIs (Issue #74) —
CarWriter,RepoCommit/SignedRepoCommit,PlcOperationBuilder,MerkleSearchTree.SerializeProof,Tid.FromInt64/ToInt64,CidComputation.TryDecodeCidString,DidDocument.Context, andXrpcClient.SetAdminCredentialsshipped without prose documentation and are now covered.docs/architecture.mdalso claimed five runtime packages (there are four) and that only the core project generates documentation (all four do) - The solution now builds with zero warnings (Issue #72) — beyond CS1591: a missing
<param>onXrpcClient.SendWithDPoPRetryAsync(CS1573), an ambiguous<see cref>inPlcOperationBuilder(CS0419), and a missing@usinginServerIntegrationSample(RZ10012)
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
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
-
v0.4.0
Stablereleased this
2026-05-26 01:18:12 +00:00 | 40 commits to main since this releaseBreaking changes
LoginFormdefault copy switched to "Atmosphere account" terminology (Issue #32) — Default English copy now reads "Sign in with your Atmosphere account" / "Your Atmosphere account handle — your PDS is detected automatically." instead of "Sign in with AT Proto" / "Your AT Protocol handle…". Apps that relied on the previous strings (e.g. UI tests asserting on button text, screenshot tests, translation overlays keyed on the old defaults) must update their expectations or pass explicitButtonText/HandleHintvalues to restore the old wording.LoginForm's string parameters also changed type fromstringtostring?to enableIStringLocalizer<LoginForm>resolution — source-compatible, no behaviour change for callers passing explicit valuesAtProtoClient.ApplyOAuthSessionAsyncsignature change — The method gained an optionalIAtProtoTokenStore? tokenStoreparameter inserted betweenoauthClientandcancellationToken. Source-compatible for callers using named arguments; binary-incompatible for positional callers — recompile required. Positional callers that previously passed(session, client, ct)must now pass(session, client, null, ct)or switch to named arguments. Required so factory-built clients can persist OAuth-refresh-rotated tokens back to the durable token storeAtProtoClientFactoryconstructor change — Constructor gained anIOAuthClientProvider? oauthClientProvider = nullparameter. Source-compatible for DI callers (Microsoft.Extensions.DependencyInjection auto-resolves the optional dependency); binary-incompatible for hand-rolled instantiation — recompile required. Without a registeredIOAuthClientProvider, factory-built per-request clients cannot refresh expired OAuth tokens
Added
-
"Atmosphere account" terminology & i18n in
LoginForm(Issue #32) — Default copy now uses the community-facing "Atmosphere account" umbrella term- New
HeadingTextandSubtitleTextparameters for an optional heading/subtitle rendered above the form (no default — rendered only when set) - Optional
IStringLocalizer<LoginForm>injection: when registered (e.g. viaservices.AddLocalization()with a.resxsource), default copy is resolved by parameter name (ButtonText,HandleLabel,HandleHint, etc.); explicit parameter values still take precedence - String parameters changed from
stringtostring?so consumers can opt in to localizer-resolved defaults
- New
-
Aspire hosting integration for PDS containers (Issue #31) — New
ATProtoNet.Aspire.Hostingpackage for adding the official Bluesky PDS container to .NET Aspire AppHostsAtProtoPdsContainerResource— Aspire container resource representing aghcr.io/bluesky-social/pdsinstance withIResourceWithConnectionStringsupportAddAtProtoPds()extension onIDistributedApplicationBuilder— Adds the PDS container with auto-generated secrets (admin password, JWT secret, PLC rotation key), dev mode enabled by default, and a persistent data volume- Fluent configuration:
WithHostname(),WithPlcUrl(),WithAppView(),WithCrawlers(),WithProductionMode(),WithBlobUploadLimit(),WithReportService(),WithEmail() - Configurable port mapping and image tag selection
- Replaces the need for manual Docker/Podman PDS setup during development
-
PDS hosting package (Issue #2) — New
ATProtoNet.Pdspackage for building AT Protocol Personal Data ServersPdsService— core business logic for account management, session handling, record CRUD, and blob operationsPdsSessionService— JWT token issuing and validation with HMAC-SHA256 signingIAccountStore/InMemoryAccountStore— pluggable account persistence with DID, handle, email, and signing key managementIRepoStore/InMemoryRepoStore— pluggable repository storage for records and blobs with cursor-based paginationPdsHostingExtensions—AddAtProtoPds()DI registration andMapAtProtoPds()XRPC endpoint mapping- Full XRPC endpoint support:
com.atproto.server.createAccount,createSession,getSession,refreshSession,describeServer - Repository endpoints:
com.atproto.repo.createRecord,getRecord,putRecord,deleteRecord,listRecords - Blob endpoints:
com.atproto.repo.uploadBlob,com.atproto.sync.getBlob - Custom store implementations via
AddAtProtoPds<TAccountStore, TRepoStore>() - PBKDF2 password hashing with 100k iterations, SHA-256 based CID computation
- Bearer token authentication with authorization checks for repo ownership
-
Native Standard.site integration (Issue #9) — First-class support for Standard.site long-form publishing lexicons
PublicationRecordmodel forsite.standard.publication— blog/site identity with URL, name, description, icon, theme, and preferencesDocumentRecordmodel forsite.standard.document— published documents with title, path, tags, content union, cover image, and Bluesky post referenceSubscriptionRecordmodel forsite.standard.graph.subscription— follow/subscribe to publicationsBasicTheme,ThemeColorRgb,ThemeColorRgbamodels forsite.standard.theme.basicandsite.standard.theme.colorStandardSiteClientwith full CRUD for publications, documents, and subscriptions via AT Protocol repo operations- Exposed as
AtProtoClient.Siteproperty, following the same pattern asBsky,Chat, andOzone
-
Lexicon migrations and publishing (Issue #14) — Schema migration pipeline and publishing workflow for the
atproto-lexgenCLI toolILexiconMigrationinterface andDelegateMigrationfor record transforms between schema revisionsMigrationBuilderfluent API for composing migrations:AddProperty,RemoveProperty,RenameProperty,ApplyLexiconMigrationRunner— builds and executes ordered migration chains, validates continuity, scaffolds migrations fromDiffResultLexiconPublisher— publishes schemas to directories with baseline diff validation, auto-revision bumping, and breaking change detectionatproto-lexgen migrateCLI command — scaffold migrations from schema diffs or apply migration files to JSON recordsatproto-lexgen publishCLI command — publish schemas with version tracking,--forcefor breaking changes,--no-bumpoption- JSON migration file format with
addProperty,removeProperty,renamePropertyoperations
-
Ozone moderation client (Issue #18) — Full
tools.ozone.*namespace support viaclient.OzoneOzoneClienttop-level client aggregating all Ozone sub-clientsModerationClient— emitEvent, getEvent, getRecord, getRepo, queryEvents, querySubjects, searchReposCommunicationClient— createTemplate, deleteTemplate, listTemplates, updateTemplateTeamClient— addMember, deleteMember, listMembers, updateMemberSetClient— upsertSet, deleteSet, addValues, deleteValues, getValues, querySetsOzoneServerClient— getConfigSignatureClient— findCorrelation, searchAccounts, findRelatedAccounts- Polymorphic moderation event types (takedown, label, comment, mute, email, tag, etc.)
SubjectReviewStateandTeamMemberRoleconstants
-
Aspire integration package (Issue #5) —
ATProtoNet.Aspirepackage for .NET Aspire service defaultsAddAtProtoClient()extension onIHostApplicationBuilder— registersAtProtoClientas a singleton with configuration binding,IHttpClientFactory, and optional standard resilienceAtProtoClientSettingsforIConfigurationbinding (InstanceUrl, RelayUrl, AutoRefreshSession, DisableHealthChecks, DisableResilience)AtProtoPdsHealthCheck— health check verifying PDS connectivity viacom.atproto.server.describeServer- Standard HTTP resilience (retry, circuit breaker) via
Microsoft.Extensions.Http.Resilience
-
Lexicon plugin support for custom types (Issue #6) — Runtime registration of custom record types and union variants via NuGet packages
ILexiconPlugininterface for plugins to register custom types at startupILexiconTypeRegistrarfor registering record types and union variants[LexiconPlugin]assembly attribute for auto-discoveryLexiconTypeRegistry— Singleton registry withLoadPlugin<T>(),LoadPluginsFromAssembly(), andCreateOptions()for plugin-aware JSON serialization- Runtime union variant registration augments built-in
[JsonDerivedType]attributes viaJsonTypeInfomodifier
-
Missing sync endpoints & Sync v1.1 support (Issue #21) — Complete com.atproto.sync coverage and Sync v1.1 fields
SyncClient.GetRepoStatusAsync— Get repository hosting statusSyncClient.ListHostsAsync— Enumerate upstream hosts consumed by a relaySyncClient.GetHostStatusAsync— Get status of a specified upstream hostSyncClient.ListReposByCollectionAsync— Enumerate DIDs with records in a given collectionAccountHostingStatusconstants: takendown, suspended, deleted, deactivated, desynchronized, throttledHostStatusconstants: active, idle, offline, throttled, bannedSyncEvent(#sync) firehose message type for Sync v1.1 repo state recoveryCommitEvent.PrevDataandCommitEvent.BlobsSync v1.1 fieldsRepoOp.Prevfield for inductive firehose verification
-
Auto-register XRPC endpoints with DI (Issue #15) — Server-side XRPC endpoint handler infrastructure
IXrpcEndpoint— Base interface for XRPC endpoint handlers with NSID identificationIXrpcQuery<TParams, TOutput>/IXrpcQuery<TOutput>— Interfaces for XRPC query endpoints (GET)IXrpcProcedure<TInput, TOutput>/IXrpcProcedureVoid<TInput>— Interfaces for XRPC procedure endpoints (POST)[XrpcEndpoint]attribute — Assembly scanning marker with optional NSID overrideAddXrpcEndpoint<T>()— Register a single XRPC endpoint handler in DIAddXrpcEndpointsFromAssembly()— Assembly scanning for[XrpcEndpoint]-attributed handlersMapXrpcEndpoints()— Maps all registered handlers as ASP.NET Core minimal API routes at/xrpc/{nsid}- Query parameter binding, JSON body deserialization, and XRPC error format support
-
Firehose event parsing, verification, and typed consumer (Issue #27) — Full firehose commit verification pipeline and advanced consumer
FirehoseEventParser— Decodes raw CBOR firehose frames into typedFirehoseMessageobjects with DAG-CBOR JSON normalizationFirehoseVerifier— CID integrity verification for commit and sync events, commit signature verification against DID document signing keysVerificationResult— Structured verification result with error detailsTypedFirehoseConsumer— High-level consumer with CBOR parsing, collection filtering, CID/signature verification, and periodic cursor persistenceTypedFirehoseConsumerOptions— Configuration for verification, collection filters, cursor persistence interval, and reconnectionIFirehoseCursorStore— Interface for persistent cursor storage enabling resumable firehose consumptionInMemoryFirehoseCursorStore— In-memory cursor store for development and testing
-
Merkle Search Tree (MST) implementation (Issue #19) — Full in-memory MST for AT Protocol repository data structure
MstKeyDepth— Computes key depth via SHA-256 leading-zero counting (fanout 4), matching AT Protocol specMstNodeData— CBOR-serializable MST node with deterministic DAG-CBOR encoding/decoding via tag 42 CID linksMerkleSearchTree— Complete MST withAdd,Update,Delete,Get,GetEntries,Serialize/Deserialize,ComputeRootCid, andValidateoperations- Layer-based top-down construction, DoS protection limits (max 256 entries/node, max 64 depth)
-
chat.bsky DM support (Issue #17) — Full Bluesky direct messaging client
ConvoClient— 17 endpoints:ListConvos,GetConvo,GetConvoForMembers,GetConvoAvailability,GetMessages,SendMessage,SendMessageBatch,DeleteMessageForSelf,LeaveConvo,MuteConvo,UnmuteConvo,UpdateRead,UpdateAllRead,AcceptConvo,AddReaction,RemoveReaction,GetLogChatActorClient—DeleteAccount,ExportAccountDataChatClientsgrouping accessible viaAtProtoClient.Chat- All chat requests automatically proxied via per-request
atproto-proxyheader (requirestransition:chat.bskyOAuth scope) - Per-request proxy override support in
XrpcClient— chat proxy doesn't affect other XRPC calls - Complete model types:
ConvoView,MessageView,DeletedMessageView,ChatMemberView,MessageInput, reaction models, all request/response types ChatDeclarationRecordandChatAllowIncomingconstants (all/none/following)
-
Labeler service support (Issue #25) — Labeler service information, label definitions, and header management
LabelerClient.GetServicesAsync— Fetch labeler service info and label value definitionsLabelerServiceRecord— Record type for declaring labeler services with policiesLabelValueDefinition— Custom label definitions with severity, blur behavior, default settings, and localized stringsLabelerViewDetailed,LabelerView,LabelerViewerState— View types for labeler servicesSetLabelers()/ClearLabelers()onXrpcClientandAtProtoClient— Automaticatproto-accept-labelersheader injectionStandardLabelValuesconstants: porn, sexual, nudity, graphic-media, gore, spam, impersonation, etc.LabelSeverity,LabelBlurs,LabelDefaultSettingconstant classesLabelerClientwired intoBlueskyClients.Labeler
-
atproto-proxy header support (Issue #24) — Route XRPC requests through AT Protocol service proxies
ServiceProxystatic helper withBuild()method and well-known constants (BskyAppView,BskyChat,AtProtoLabeler,AtProtoPds)- Pre-built header values:
BskyAppViewHeader,BskyChatHeader,BskyAppViewDid,BskyChatDid SetProxy()/ClearProxy()methods on bothXrpcClientandAtProtoClient
-
did:web resolver & unified DID resolution (Issue #28) — Resolve
did:webidentifiers and dispatch to correct resolverDidWebResolver— Fetcheshttps://<domain>/.well-known/did.json, validates document ID matches, SSRF prevention (IP address blocking), HTTPS enforcement, localhost exception for developmentDidResolver— Unified dispatcher:did:plc→PlcClient,did:web→DidWebResolverDidWebExceptionwith typedDidWebErrorKind(InvalidDid, NotFound, HttpError, NetworkError, ParseError, ValidationError)
-
Missing Bluesky graph features (Issue #26) — Starter packs, relationships, thread muting, and postgate support
- Records:
StarterPackRecord,StarterPackFeedItem,PostgateRecord - Views:
StarterPackView,StarterPackViewBasic - Relationships:
Relationship,NotFoundActor,GetRelationshipsResponse,GetKnownFollowersResponse - Starter pack responses:
GetStarterPackResponse,GetStarterPacksResponse,GetActorStarterPacksResponse,SearchStarterPacksResponse GraphClientmethods:GetRelationshipsAsync,GetKnownFollowersAsync,MuteThreadAsync,UnmuteThreadAsync,GetStarterPackAsync,GetStarterPacksAsync,GetActorStarterPacksAsync,SearchStarterPacksAsync
- Records:
-
Video upload & processing client (Issue #22) —
app.bsky.video.*XRPC endpointsVideoClientwithUploadVideoAsync,GetJobStatusAsync,GetUploadLimitsAsyncVideoModels:JobStatus,JobStateconstants,GetJobStatusResponse,UploadVideoResponse,GetUploadLimitsResponseVideoClientwired intoBlueskyClients.Video
-
Well-known Bluesky permission set NSIDs (Issue #29) —
AtProtoScopes.PermissionSetsconstants- Constants for all
app.bsky.auth*permission sets:FullApp,ManageProfile,CreatePosts,DeletePosts,ManagePosts,ManageFollows,ManageListsAndPacks,ViewNotifications,ManageNotifications,ManageFeedDeclarations,ManageLabelerService,ManagePreferences,ManageModeration,ViewAll
- Constants for all
-
Atproto-Repo-Rev header tracking (Issue #30) — Automatic extraction and exposure of repository revision headers
LatestRepoRevproperty onXrpcClientandAtProtoClient- Extracted from all XRPC responses via the
Atproto-Repo-Revheader
-
HTTP rate limiting with automatic retry (Issue #23) — Built-in 429 handling with configurable retry behavior
RateLimitInfomodel with Limit, Remaining, Reset, and IsExceeded properties- Automatic retry on HTTP 429 with
Retry-After/RateLimit-Resetheader support and exponential backoff fallback LatestRateLimitInfoproperty onXrpcClientandAtProtoClient- Configurable
MaxRateLimitRetries(default: 3, set to 0 to disable)
-
DAG-CBOR encoding/decoding layer (Issue #20) — DRISL-CBOR implementation for AT Protocol data model
DagCborEncoder— Deterministic CBOR encoding with sorted map keys,$link→ CID tag 42,$bytes→ byte string, float rejectionDagCborDecoder— CBOR decoding with CID tag 42 →$link, byte string →$bytes, validation of sorted keys and no-float constraintsCidComputation— CIDv1 computation with SHA-256, DAG-CBOR (0x71) and raw (0x55) codecs, Base32Lower encoding/decoding, CID verification
-
OAuth scope constants & granular permission builders (
AtProtoScopes) — Full AT Protocol Permissions spec support- Transitional scope constants:
AtProto,TransitionGeneric,TransitionChatBsky,TransitionEmail - Convenience presets:
Default,WithChat,AuthOnly Repo()— Record collection permissions withRepoActionflags (Create, Update, Delete), single or multiple collections, wildcard supportRpc()— Service authentication (RPC) permissions with Lexicon method and audience parameters, DID fragment encodingBlob()— Blob upload permissions with MIME type patterns (*/*,video/*, etc.)Account()— Account attribute permissions (email, repo, status) with Read/Manage actionsIdentity()— Identity attribute permissions (handle, wildcard) with Manage/Submit actionsInclude()— Permission set references for published Lexicon-based permission bundles with optional audience inheritanceCombine()— Merge and deduplicate multiple scope strings- Replaced hardcoded scope strings in
OAuthModelsandAtProtoOAuthServerOptionswithAtProtoScopes.Default
- Transitional scope constants:
-
Custom relay URL configuration (Issue #8) — Configurable relay WebSocket URL for firehose
WithRelayUrl()onAtProtoClientBuilder(default:wss://bsky.network)RelayUrlproperty onAtProtoClientOptionsCreateFirehoseClient()andCreateFirehoseConsumer()convenience methods onAtProtoClient
-
EF Core token store (
ATProtoNet.Server.EntityFrameworkCore) — New package for database-backed token storage (Issue #3)EfCoreAtProtoTokenStore<TContext>— GenericIAtProtoTokenStoreimplementation usingIDbContextFactory<TContext>- ASP.NET Core Data Protection encryption for stored tokens
AtProtoTokenEntitywith DID primary keyAtProtoTokenDbContextwithConfigureAtProtoTokenModel()for use in custom DbContextsAddAtProtoEfCoreTokenStore<TContext>()DI extension
-
Security hardening — Comprehensive SSRF prevention, TLS enforcement, and input validation
- Accurate private IP range detection using
IPAddress.TryParsecovering RFC 1918, CGN (100.64/10), loopback, link-local, and IPv6 private ranges - IPv6 bracket host blocking in DID:web resolution (all bracketed IPs rejected — use domain names)
- TLS enforcement in
XrpcClient.SetBaseUrl()— HTTP only allowed for localhost/loopback - Exact token matching for
atprotoscope validation (prevents substring false-positives) - Open redirect prevention in OAuth callback return URLs
- Error message sanitization (truncation to 200 chars) to prevent leaking internal details
- DPoP key disposal on all OAuth error paths (prevents cryptographic key leaks)
- Concurrent session refresh guard via
SemaphoreSliminAtProtoClient - Restrictive Unix file permissions (700) on
FileAtProtoTokenStoredirectory - 54 new security-focused tests (362 total)
- Accurate private IP range detection using
-
Aspire auto-detection — Automatic HTTP loopback URL discovery for AT Proto OAuth
TryGetLoopbackHttpUrl()inspectsIServerAddressesFeaturefor HTTP bindings when request arrives on HTTPS- Normalizes
localhost→127.0.0.1for AT Proto loopback compatibility - Zero-config: works automatically with Aspire, Kestrel multi-bind, and reverse proxy setups
-
Transparent cross-origin cookie relay — Automatic auth cookie relay for localhost/127.0.0.1 mismatch
- AT Proto loopback OAuth requires
http://127.0.0.1for the callback, but the user's browser may be onhttps://localhost(e.g., in Aspire). The auth cookie set on127.0.0.1is invisible onlocalhost. - The SDK now detects when the callback origin differs from the login origin, generates a one-time relay code (128-bit, 2-minute expiry), and redirects to
{loginOrigin}/atproto/relay?code=xxxto issue the cookie on the correct domain. - Return URL is stored server-side (keyed by OAuth state) instead of only in a cookie, fixing the cross-domain cookie loss.
- Zero-config: No
BaseUrl,OnSigningInhooks, or relay middleware needed. JustAddAtProtoAuthentication()+MapAtProtoOAuth(). - 22 new cookie relay tests (384 total)
- AT Proto loopback OAuth requires
-
Lexicon code generator — Bidirectional
dotnet tool(atproto-lexgen) for AT Protocol Lexicon schemasatproto-lexgen csharp— Generate C# classes from Lexicon JSON schema files (records, objects, enums, tokens)atproto-lexgen lexicon— Generate Lexicon JSON schemas from compiled .NET assemblies via reflectionatproto-lexgen diff— Compare baseline and current Lexicon schemas, detect breaking changes per AT Protocol evolution rules- Matches existing SDK patterns:
sealed class,required/initproperties,[JsonPropertyName],$typeexpression-body - Supports all Lexicon types: record, object, string enum, token, ref, union, array, blob
- Schema evolution validation: detects added/removed properties, type changes, required status changes, constraint tightening
--strictmode exits with code 1 on breaking changes (for CI integration)- Automatic revision bump suggestions for non-breaking changes
-
Cryptography utilities (
AtProtoCrypto,AtProtoKey) — AT Protocol cryptographic operations- P-256 (NIST secp256r1) and K-256 (secp256k1) key pair generation
- ECDSA signing and verification with SHA-256 and low-S normalization
- Compressed public key export/import with EC point decompression (modular arithmetic)
- Multikey encoding/decoding (base58btc with multicodec prefix)
did:keygeneration and parsing (round-trips through multikey)- PKCS#8 private key export/import
- Base58 Bitcoin encoding/decoding
-
CAR file reader (
CarReader) — Parse Content Addressable aRchive (CAR v1) files- Used for consuming
com.atproto.sync.getReporesponses - CID parsing (CIDv0 and CIDv1), DAG-CBOR header decoding
- Block lookup by CID, root block access
- Stream and byte array input support
- Used for consuming
-
PLC directory client (
PlcClient) — Interact with PLC directory servers- DID document resolution (
ResolveDidAsync) with 404/410 error handling - Operation log, audit log, and latest operation retrieval
- Current PLC data access
- Health check endpoint
- Full DID document model:
DidDocument,VerificationMethod,ServiceEndpoint - PLC operation model:
PlcOperation,PlcAuditEntry,PlcData - Convenience methods:
GetHandle(),GetPdsEndpoint()onDidDocument
- DID document resolution (
-
Service auth JWT generation (
ServiceAuthGenerator) — Inter-service authentication- JWT generation with
iss(service DID),aud(target),exp,iat,jti,lxmclaims - ES256 (P-256) and ES256K (K-256) signing via
AtProtoKey - 60-second default expiry, 5-minute maximum enforcement
- Used for Feed Generators, Labelers, and relay services
- JWT generation with
-
Lexicon code generator packaging —
atproto-lexgenis now a publishabledotnet tool- NuGet package metadata:
PackageId,Version,Authors,PackageTags,License,RepositoryUrl - Install globally via
dotnet tool install -g ATProtoNet.LexiconGenerator
- NuGet package metadata:
-
Documentation — Comprehensive documentation for all new features
- New guides: PDS Hosting, Chat & DMs, Ozone Moderation, Standard.site, .NET Aspire, Video Upload, Labeler Services, Cryptography, DID Resolution, Lexicon Code Generator, XRPC Endpoint Handlers
- Updated guides: Firehose Streaming (TypedFirehoseConsumer, verification, cursor persistence), Getting Started (new packages, builder options), Server Integration (EF Core token store), API Reference (all new client types)
Fixed
-
OAuth, firehose, and repo correctness pass (F1–F15 + G1–G14 + review follow-up) — Series of fixes addressing review findings across the OAuth, firehose, and repo subsystems
- Commit signature verification (
FirehoseVerifier) — UseCborConformanceMode.Strictinstead ofCtap2Canonical. The previous mode forbade all CBOR tags, but DAG-CBOR requires tag 42 for CIDs, so every real commit threwCborContentExceptionand verification failed for the wrong reason. Canonical-form integrity is preserved by the byte-for-byte splice of the original buffer - MST canonical form (
MerkleSearchTree) — Restored empty parent-layer wrapping inSplitAndInsertand added matching empty-parent wrapping inBuildLayerTopDownso incrementalAddand bulkCreateFromEntriesproduce the same root CID as atproto/ts.Create(entries)now delegates toCreateFromEntriesso both public factories use the spec-conformant builder - Firehose at-least-once semantics (
FirehoseConsumer) — Reconnect cursor only advances when the consumer callsAcknowledge(seq). WhenAcknowledgeis never called, the cursor falls back to the current frame's seq (at-most-once); the docstring spells out the contract explicitly. The monotonic floor is now pre-seeded with the caller's resume cursor so a hostile first frame can't rewind below the intended resume point - CAR block CID codec policy (
CarReader+FirehoseVerifier) —VerifyAllBlockCidsnow throws onUnknownCodecin addition toMismatch. The staticFirehoseVerifier.VerifyCarBlockCidspath also fails closed onUnknownCodec, so the cheap pre-check and the full signature path apply the same policy - OAuth refresh persistence (
AtProtoClient) — Rotated tokens are written toIAtProtoTokenStoreBEFORE the in-memory session is mutated. A store-write failure now surfaces immediately rather than silently desyncing memory and disk (the old failure mode left the persisted store with the dead refresh token, logging users out on next process restart) - OAuth refresh token store wired —
AtProtoClient.ApplyOAuthSessionAsyncgained an optionalIAtProtoTokenStore? tokenStoreparameter thatAtProtoClientFactorypasses through, so refresh-rotated tokens land in durable storage instead of only the per-requestInMemorySessionStore - Refresh-lock around
ApplyOAuthSessionAsync— The session swap now holds_refreshLock, preventing a timer-driven refresh from racing the swap and corrupting state - Bounded timer-driven refresh —
OnRefreshTimerElapseduses a 30-secondCancellationTokenSourceso a slow token endpoint can't pin_refreshLockindefinitely and block foregroundLogoutAsync/ApplyOAuthSessionAsync Disposerace with timer callback — SyncDispose()drains in-flight callbacks viaTimer.Dispose(WaitHandle); the callback'sReleaseis wrapped intry/catch ObjectDisposedExceptionso a late-firing release on a disposed semaphore can no longer escapeasync voidand crash the process._oauthSessionand_refreshLockare now disposed inDisposeandDisposeAsyncLogoutAsyncclears_oauthTokenStore— Defensive cleanup so a subsequent re-login with a differenttokenStorearg doesn't inherit a stale referenceOAuthClientconstructed lazily onIOAuthClientProvider.TryGetClient— Only when explicitClientMetadatais configured (the production case). Loopback callers must still driveStartLoginAsyncto materialize a client, since the loopbackclient_idencodes the live request's callback URL- JWT pre-validator algorithm allowlist (
AtProtoAuthenticationHandler) — Now allowlistsES256/ES256K/ES384/ES512/EdDSA/RS256/RS384/RS512/PS256/PS384/PS512only. Previously only rejectedalg=none, so symmetric HS256 forgeries reached the PDS unchallenged - Handle resolution requires HTTPS + DNS agreement (
AuthorizationServerDiscovery) —ResolveHandleAuthoritativeAsyncnow runs HTTPS well-known and DNS-over-HTTPS lookups concurrently and fails closed when they return different DIDs. (Note: both transports currently share the same TLS trust root viadns.google— true authority diversification needs a system DNS path) did:webid comparison is case-insensitive for host (AuthorizationServerDiscovery) — DNS host names are case-insensitive per RFC 1035; the prior strictOrdinalcompare rejected validdid:web:Example.comdocuments.did:plcremains strictly case-sensitiveAtProtoTokenData/OAuthSessionResultgainedIsHandleVerified— Persisted and restored across factory hydration. Default-claims now emit"handle.invalid"asClaimTypes.Namewhen the handle isn't bidirectionally verified, with an explicithandle_verifiedclaim alongside the actualdidandhandle. Behavior change for existing OAuth sessions: tokens persisted before this release deserialize withIsHandleVerified=false, soUser.Identity.Nameshows"handle.invalid"until users re-loginTryReadSeqpropagatesOperationCanceledException— Previously swallowed by an unfiltered catch, breaking cancellation propagation through the cursor-advance logicWriteMapHeaderrejects oversized counts — ThrowsArgumentOutOfRangeExceptionon negative counts and now emits the 4-byte (CBOR 0x1a) header for counts ≥ 65536. Previously silently truncated to 16 bits, producing malformed CBORAtProtoClient.Dispose/DisposeAsyncreleases_oauthSessionand_refreshLock— DPoP ECDsa key and SemaphoreSlim wait handles no longer leak to GC finalization
- Commit signature verification (
-
Packaging & release pipeline — Release artifact hygiene
Aspire.Hostingdependency inATProtoNet.Aspire.Hostingupgraded from9.2.1to9.5.2, picking upKubernetesClient 17.0.14and resolving the transitive moderate-severity NU1902 advisory (GHSA-w7r3-mgwf-4mqq)Microsoft.EntityFrameworkCore.Relationaldependency inATProtoNet.Server.EntityFrameworkCoreupgraded from10.0.0-preview.4.25258.110to stable10.0.0(resolves NU5104 "stable release should not have a prerelease dependency")FirehoseConsumerSamplemarkedIsPackable=falseso it no longer leaks intodotnet packoutput- Removed duplicate
README.md<None Include>items fromATProtoNet,ATProtoNet.Server, andATProtoNet.Blazorcsprojs —Directory.Build.propsalready packs the root README into every package (resolves NU5118) - Removed stale hardcoded
<Version>0.3.0</Version>and duplicated package metadata fromATProtoNet.LexiconGenerator.csprojso it inherits the shared version fromDirectory.Build.props - Removed the
packagejob from.forgejo/workflows/ci.yml; publishing is now driven exclusively by thereleaseworkflow (triggered byv*tags or manualworkflow_dispatch), so version bumps onmainno longer publish to the Forgejo NuGet feed before a release tag is cut
-
Issue templates — Converted from invalid hybrid format (YAML frontmatter + Markdown body in
.ymlfiles) to proper Forgejo YAML form templates with structuredbody:sections -
Cryptographic security hardening — Fixes from security audit of crypto primitives
- Low-S normalization —
NormalizeLowSwas a complete no-op (dead code). Now compares S against the actual curve half-order and computesorder - Swhen needed. Prevents signature malleability. - High-S signature rejection —
Verify()now rejects signatures with S > half-order, enforcing AT Protocol's low-S requirement ImportPrivateKeycurve validation — Validates the imported key's curve OID matches the declaredKeyCurveparameter. Prevents silent identity corruption from curve mismatch.DecompressPointrange check — Validates X coordinate is in range[0, p)before modular arithmetic- JWT
audiencevalidation —ServiceAuthGenerator.CreateTokennow rejects null/whitespace audience - Base58 performance — Replaced LINQ
.Any()with aforloop in hot path - 4 new crypto security tests (455 total)
- Low-S normalization —
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
4 downloads
-
v0.3.0
Stablereleased this
2026-02-21 01:15:21 +00:00 | 81 commits to main since this releaseAdded
-
Cookie-based OAuth for Blazor — Standard cookie authentication that works with
<AuthorizeView>,[Authorize], and all built-in Blazor auth patternsAddAtProtoAuthentication()— registers OAuth service and optionsMapAtProtoOAuth()— maps/atproto/login,/atproto/callback,/atproto/logoutendpoints- Auto-generated loopback
client_idfor zero-config development - Configurable claims via
ClaimsFactoryoption - Default claims: DID, handle, PDS URL, auth method
-
Server-side AT Protocol access — Backend API integration via
IAtProtoClientFactoryAddAtProtoServer()— registers token store, client factory, and HTTP clientIAtProtoClientFactory— creates per-request authenticatedAtProtoClientfrom stored OAuth tokensIAtProtoTokenStore— interface for multi-user server-side token storageFileAtProtoTokenStore(default) — persistent file-based token storage with ASP.NET Core Data Protection encryptionInMemoryAtProtoTokenStore— volatile in-memory store for development/testingAddAtProtoServer(string tokenDirectory)overload for custom token storage directoryAtProtoTokenData— serializable token data including DPoP private key- Blazor OAuth service automatically stores/removes tokens when
IAtProtoTokenStoreis registered
-
Rewritten
LoginFormcomponent — Pure HTML form that submits to the login endpoint- Fully customizable labels for localization
- Optional PDS URL input for custom PDS connections
- Auto-displays OAuth callback errors
-
ServerIntegrationSample — New sample showing Blazor OAuth + backend AT Proto access
- Minimal API endpoints (
/api/profile,/api/timeline) - Blazor pages using
IAtProtoClientFactorydirectly - Profile and timeline views
- Minimal API endpoints (
Fixed
- DPoP nonce handling —
AtProtoClientFactorynow passesnullDPoP nonces instead of stale stored values; the XRPC client's retry logic acquires fresh nonces on first request, preventinguse_dpop_nonce401 errors
Changed
- ATProtoNet.Blazor.csproj — Replaced individual NuGet package references with
<FrameworkReference Include="Microsoft.AspNetCore.App" /> - ATProtoNet.Server
ServiceCollectionExtensions— AddedAddAtProtoServer()for OAuth-based multi-user access; default token store changed fromInMemoryAtProtoTokenStoretoFileAtProtoTokenStore; improved docs on existingAddAtProto()andAddAtProtoScoped()methods
Removed
- BREAKING:
AddAtProtoBlazor()extension method — replaced byAddAtProtoAuthentication() - BREAKING:
AtProtoAuthStateProvider— no longer needed; standardServerAuthenticationStateProviderworks via cookies - BREAKING:
OAuthCallbackcomponent — callback is now an HTTP endpoint mapped byMapAtProtoOAuth() - BREAKING:
PdsOptionmodel — PDS selection is now a simple text input inLoginForm - BREAKING:
BlazorServiceCollectionExtensionsclass — replaced byAtProtoAuthenticationExtensions
Downloads
-
Source code (ZIP)
2 downloads
-
Source code (TAR.GZ)
7 downloads
-
-
v0.2.0
Stablereleased this
2026-02-20 23:39:00 +00:00 | 82 commits to main since this releaseAdded
-
OAuth Authentication — Full AT Protocol OAuth implementation
- DPoP (RFC 9449) — proof-of-possession bound tokens with ES256 (P-256) key pairs
- Pushed Authorization Requests (RFC 9126) — secure authorization initiation
- PKCE (RFC 7636) — S256 code challenge for public clients
- Authorization Server Discovery — full resolution chain (Handle → DID → PDS → AS)
- Identity verification — DID/issuer consistency checks after token exchange
- Token refresh with DPoP binding
OAuthClientorchestrator withStartAuthorizationAsync()/CompleteAuthorizationAsync()AuthorizationServerDiscoveryfor handle, DID, and PDS resolutionDPoPProofGeneratorfor ES256 DPoP proof JWT generationPkceGeneratorfor PKCE S256 code verifier and challenge generation- Complete
OAuthModels— client metadata, server metadata, token responses, DID documents
-
Dynamic PDS Selection — Connect to any AT Protocol PDS at runtime
AtProtoClient.SetPdsUrl()— change PDS URL dynamicallyAtProtoClient.ApplyOAuthSessionAsync()— apply OAuth session with DPoP tokensXrpcClient.SetBaseUrl()— runtime base URL changes- OAuth flow automatically resolves user's PDS from their identity
-
Blazor OAuth Components
LoginForm— redesigned with PDS selector, OAuth toggle, custom PDS URL inputOAuthCallback— callback handler component for OAuth redirectPdsOption— model for PDS dropdown optionsAtProtoAuthStateProvider— OAuth-aware auth state withStartOAuthLoginAsync()andCompleteOAuthLoginAsync()AddAtProtoBlazor()— now registersOAuthClientwhen OAuth options are configured
-
Security hardening
- Handle format validation (SSRF prevention)
- DID:web host validation (private IP blocking)
- Redirect URI HTTPS enforcement (localhost exception for dev)
- DID format validation on token response
subclaim - Pending authorization cleanup (10-minute expiry, 100 max entries)
- DPoP private key export security documentation
-
Sample project
samples/BlazorOAuthSample— minimal Blazor Server app demonstrating OAuth login with loopback client
-
Documentation
- OAuth authentication guide (
docs/oauth.md) with loopback client development section - Updated Blazor, session management, and getting started guides
- Updated README with OAuth sections
- OAuth authentication guide (
-
Tests
- 50 new unit tests for OAuth components (DPoP, PKCE, models, dynamic PDS)
- Total: 268 unit tests
Downloads
-
Source code (ZIP)
2 downloads
-
Source code (TAR.GZ)
3 downloads
-
-
released this
2026-02-20 18:41:46 +00:00 | 86 commits to main since this releaseWhat's Changed
Fixed
- Timestamp formatting: All timestamps now use AT Protocol spec-compliant millisecond precision (
2026-02-20T18:19:03.889Z) instead of the overly precise 7-digit format (2026-02-20T18:19:03.8899309Z) previously generated byDateTime.ToString("o").
Added
AtProtoJsonDefaults.FormatTimestamp(DateTime)— formats anyDateTimeto AT Proto spec-compliant string.AtProtoJsonDefaults.NowTimestamp()— returns the current UTC time as an AT Proto timestamp.
Packages
Install from the Forgejo NuGet registry:
<PackageReference Include="ATProtoNet" Version="0.1.1" /> <PackageReference Include="ATProtoNet.Server" Version="0.1.1" /> <PackageReference Include="ATProtoNet.Blazor" Version="0.1.1" />Full changelog: v0.1.0...v0.1.1
Downloads
-
Source code (ZIP)
1 download
-
Source code (TAR.GZ)
3 downloads
- Timestamp formatting: All timestamps now use AT Protocol spec-compliant millisecond precision (
-
v0.1.0
Stablereleased this
2026-02-19 22:50:09 +00:00 | 95 commits to main since this releaseATProto.NET v0.1.0 — Initial Release
A comprehensive .NET SDK for the AT Protocol with focus on custom lexicon applications.
Packages
Install from the Forgejo NuGet registry:
<PackageReference Include="ATProtoNet" Version="0.1.0" /> <PackageReference Include="ATProtoNet.Server" Version="0.1.0" /> <PackageReference Include="ATProtoNet.Blazor" Version="0.1.0" />Highlights
- RecordCollection<T> — typed CRUD for custom lexicon records
- Custom XRPC — call any AT Proto endpoint with QueryAsync/ProcedureAsync
- Identity types — Did, Handle, AtUri, Nsid, Cid, Tid, RecordKey
- Session management — auto-refresh, persistence via ISessionStore
- ASP.NET Core — DI, authentication handler
- Blazor — login, profile, feed components
- Firehose — real-time event streaming
- 218 unit tests, 20 integration tests passing against real PDS
See the documentation for full guides.
Downloads
-
Source code (ZIP)
1 download
-
Source code (TAR.GZ)
4 downloads