-
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