Spaces: server-side support in ATProtoNet.Server (space authority and repo host) #91

Closed
opened 2026-08-20 21:08:47 +00:00 by claude-bot · 2 comments
Collaborator

#89 landed the client side of the permissioned data protocol: an application can create spaces, read and write records, obtain space credentials, and sync. What it cannot do is serve a space.

A .NET service acting as a space authority or a repo host needs the other half, and none of it exists yet:

  • XRPC handlers for the host-side methods, registered through the existing Xrpc/ handler routing in ATProtoNet.Server: getSpaceCredential, listRepos, registerNotify, unregisterNotify, and the repo-side getRecord / listRecords / getBlob / listBlobs / getLatestCommit / getRepo / listRepoOps.
  • Delegation token verification — parse, check aud against spaceHostAud(spaceDid) derived from sub (a token minted for one authority must not work at another), verify against the issuer's #atproto key, and enforce single use via a replay store keyed on (iss, jti, exp). SpaceTokens.Verify does the parsing and signature half already; the replay store and the ASP.NET plumbing do not exist.
  • DPoP proof verification for space credentials (RFC 9449): verify the signature against the proof's own embedded jwk, check that its thumbprint matches the credential's cnf.jkt, check ath against the presented credential, check htm/htu against the request as received, and reject a replayed jti. The SDK only generates proofs today (DPoPProofGenerator).
  • Client attestation verification — resolve iss (the client_id) to its client-metadata.json, fetch the published JWKS (jwks or jwks_uri), and verify against the key named by the attestation's kid.
  • com.atproto.simplespace.* handlers plus the member-list / policy storage they consult at credential-mint time, and the checkUserAccess call out to a managingApp.
  • Write notification delivery — auto-registering the space authority as a subscriber on the first write into a shared space, and fanning notifyWrite out to registered syncers with service auth.

The primitives are all in ATProtoNet.Spaces already (SpaceTokens, SpaceCommitVerifier, SpaceRepoCar.Serialize, SpaceAuthority); this is the ASP.NET Core layer over them.

Worth splitting further — the auth verifiers are probably one issue and the handler surface another. See docs/spaces.md and proposal 0016.


Filed by Claude while working on #89 (run).

#89 landed the **client** side of the permissioned data protocol: an application can create spaces, read and write records, obtain space credentials, and sync. What it cannot do is *serve* a space. A .NET service acting as a **space authority** or a **repo host** needs the other half, and none of it exists yet: - **XRPC handlers** for the host-side methods, registered through the existing `Xrpc/` handler routing in `ATProtoNet.Server`: `getSpaceCredential`, `listRepos`, `registerNotify`, `unregisterNotify`, and the repo-side `getRecord` / `listRecords` / `getBlob` / `listBlobs` / `getLatestCommit` / `getRepo` / `listRepoOps`. - **Delegation token verification** — parse, check `aud` against `spaceHostAud(spaceDid)` derived from `sub` (a token minted for one authority must not work at another), verify against the issuer's `#atproto` key, and enforce single use via a replay store keyed on `(iss, jti, exp)`. `SpaceTokens.Verify` does the parsing and signature half already; the replay store and the ASP.NET plumbing do not exist. - **DPoP proof verification** for space credentials (RFC 9449): verify the signature against the proof's own embedded `jwk`, check that its thumbprint matches the credential's `cnf.jkt`, check `ath` against the presented credential, check `htm`/`htu` against the request as received, and reject a replayed `jti`. The SDK only *generates* proofs today (`DPoPProofGenerator`). - **Client attestation verification** — resolve `iss` (the `client_id`) to its `client-metadata.json`, fetch the published JWKS (`jwks` or `jwks_uri`), and verify against the key named by the attestation's `kid`. - **`com.atproto.simplespace.*` handlers** plus the member-list / policy storage they consult at credential-mint time, and the `checkUserAccess` call out to a `managingApp`. - **Write notification delivery** — auto-registering the space authority as a subscriber on the first write into a shared space, and fanning `notifyWrite` out to registered syncers with service auth. The primitives are all in `ATProtoNet.Spaces` already (`SpaceTokens`, `SpaceCommitVerifier`, `SpaceRepoCar.Serialize`, `SpaceAuthority`); this is the ASP.NET Core layer over them. Worth splitting further — the auth verifiers are probably one issue and the handler surface another. See `docs/spaces.md` and [proposal 0016](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data). --- _Filed by Claude while working on #89 ([run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/314))._
Author
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

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

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/318); the reply will link the full session transcript. <!-- claude-run:comment --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-91/run-318.md) — updating every ~5 minutes while the task runs.
Author
Collaborator

Pushed claude/issue-91.

What I built

The ASP.NET Core layer over the ATProtoNet.Spaces primitives, so a .NET service can act as a space authority, a repo host, or both. It registers as three separate opt-ins — a route that answers is a route that has to be secured, and a service that only needs to verify (a moderation service, a proxy) takes just the first:

builder.Services
    .AddAtProtoSpaces(o => { o.ServiceDid = "…"; o.PublicBaseUrl = "https://pds.example.com"; })
    .AddSpaceAuthority<MyAuthorityStore>(credentialSigningKey)
    .AddSimpleSpace<MySimpleSpaceStore>()
    .AddSpaceRepoHost<MyRepoHost>();

app.MapXrpcEndpoints();   // the existing routing maps them

The verifiers are the half that has to be right. Three checks carry the protocol's guarantees:

  • A delegation token is confined to one authority. Its aud must equal spaceHostAud(spaceDid) for the authority named in the token's own sub — derived from the token, never taken from the request. An authority handed a token minted for a different authority cannot present it there.
  • A credential's signer comes from the space URI, not from the credential's iss, so nobody but a space's authority can mint credentials for it (#atproto_space preferred, #atproto fallback).
  • A DPoP proof binds a credential to its holder. The signature verifies against the proof's own embedded jwk — which proves nothing alone, since anyone can embed any key — so the thumbprint is matched against the credential's cnf.jkt, which is what makes it mean something. ath pins the proof to the credential presented, htm/htu to this request as received, iat bounds its useful life, jti is spent once.

Single use is ISpaceReplayStore, keyed on (iss, jti, exp) and consumed last — only once every other check has passed, so a forged token cannot burn the identifier of one a legitimate holder is about to present.

Also: the full endpoint surface (authority + repo host), the seven com.atproto.simplespace methods with the baseline access policy (both perimeters, app first, checkUserAccess out to a managing app — and an unreachable managing app refuses, because failing open would turn every outage into an open space), and best-effort notifyWrite fan-out with first-write auto-registration of the authority.

Two general additions to the XRPC routing came out of it and are reusable: XrpcException, so a handler produces the named error clients branch on rather than a 500, and IXrpcBlobQuery<TParams> for methods whose output encoding isn't application/json (getRepo, getBlob).

Verified

dotnet build clean, 0 warnings (the project promotes CS1591 to an error). 1601 unit tests pass in both Debug and Release, 90 of them new — including a TestServer pass over the real HTTP surface from the credential exchange through a repo read authenticated with the credential it produced. The negative cases are the point: a token minted for another authority, a credential signed by an authority that doesn't gate the space, a stolen credential presented with the thief's own key, a proof minted for another host or another credential, a replayed token, an attestation signed by a key the client doesn't publish.

Scope

The issue suggested splitting; I did all six bullets in one change instead, since the verifiers and the handlers are hard to test apart. What I deliberately left out is the write path (createRecord/putRecord/applyWrites/getDelegationToken/listSpaces) — that's served by a user's PDS over its own OAuth session and needs the record store, oplog, and scope evaluation a PDS already has. This repo doesn't implement a PDS, so it isn't a gap here; docs/spaces.md says so explicitly.

docs/spaces.md gains a Serving a space section; api-reference.md, architecture.md, index.md, README.md and CHANGELOG.md updated.


⏱ 35m 5s · 107 turns · tokens: 340.9k in / 182.0k out (+22263.4k cache-read)
📜 Full session transcript
→ Opened PR: #101
→ Filed follow-up issue #102: Spaces: durable and multi-instance implementations of the space server stores

Pushed `claude/issue-91`. ## What I built The ASP.NET Core layer over the `ATProtoNet.Spaces` primitives, so a .NET service can act as a **space authority**, a **repo host**, or both. It registers as three separate opt-ins — a route that answers is a route that has to be secured, and a service that only needs to *verify* (a moderation service, a proxy) takes just the first: ```csharp builder.Services .AddAtProtoSpaces(o => { o.ServiceDid = "…"; o.PublicBaseUrl = "https://pds.example.com"; }) .AddSpaceAuthority<MyAuthorityStore>(credentialSigningKey) .AddSimpleSpace<MySimpleSpaceStore>() .AddSpaceRepoHost<MyRepoHost>(); app.MapXrpcEndpoints(); // the existing routing maps them ``` **The verifiers** are the half that has to be right. Three checks carry the protocol's guarantees: - **A delegation token is confined to one authority.** Its `aud` must equal `spaceHostAud(spaceDid)` for the authority named in the token's *own* `sub` — derived from the token, never taken from the request. An authority handed a token minted for a different authority cannot present it there. - **A credential's signer comes from the space URI**, not from the credential's `iss`, so nobody but a space's authority can mint credentials for it (`#atproto_space` preferred, `#atproto` fallback). - **A DPoP proof binds a credential to its holder.** The signature verifies against the proof's *own* embedded `jwk` — which proves nothing alone, since anyone can embed any key — so the thumbprint is matched against the credential's `cnf.jkt`, which is what makes it mean something. `ath` pins the proof to the credential presented, `htm`/`htu` to this request as received, `iat` bounds its useful life, `jti` is spent once. Single use is `ISpaceReplayStore`, keyed on `(iss, jti, exp)` and consumed **last** — only once every other check has passed, so a forged token cannot burn the identifier of one a legitimate holder is about to present. Also: the full endpoint surface (authority + repo host), the seven `com.atproto.simplespace` methods with the baseline access policy (both perimeters, app first, `checkUserAccess` out to a managing app — and an unreachable managing app *refuses*, because failing open would turn every outage into an open space), and best-effort `notifyWrite` fan-out with first-write auto-registration of the authority. Two general additions to the XRPC routing came out of it and are reusable: `XrpcException`, so a handler produces the named error clients branch on rather than a 500, and `IXrpcBlobQuery<TParams>` for methods whose output `encoding` isn't `application/json` (`getRepo`, `getBlob`). ## Verified `dotnet build` clean, 0 warnings (the project promotes CS1591 to an error). **1601 unit tests pass in both Debug and Release**, 90 of them new — including a `TestServer` pass over the real HTTP surface from the credential exchange through a repo read authenticated with the credential it produced. The negative cases are the point: a token minted for another authority, a credential signed by an authority that doesn't gate the space, a stolen credential presented with the thief's own key, a proof minted for another host or another credential, a replayed token, an attestation signed by a key the client doesn't publish. ## Scope The issue suggested splitting; I did all six bullets in one change instead, since the verifiers and the handlers are hard to test apart. What I deliberately left out is the **write** path (`createRecord`/`putRecord`/`applyWrites`/`getDelegationToken`/`listSpaces`) — that's served by a user's PDS over its own OAuth session and needs the record store, oplog, and scope evaluation a PDS already has. This repo doesn't implement a PDS, so it isn't a gap here; `docs/spaces.md` says so explicitly. `docs/spaces.md` gains a *Serving a space* section; `api-reference.md`, `architecture.md`, `index.md`, `README.md` and `CHANGELOG.md` updated. --- ⏱ 35m 5s · 107 turns · tokens: 340.9k in / 182.0k out (+22263.4k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-91/run-318.md) → Opened PR: https://git.grandiras.net/Grandiras/ATProto.NET/pulls/101 → Filed follow-up issue [#102](https://git.grandiras.net/Grandiras/ATProto.NET/issues/102): Spaces: durable and multi-instance implementations of the space server stores
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#91
No description provided.