ATProtoNet.Aspire (0.1.1)

Published 2026-04-12 22:21:08 +00:00 by Grandiras

Installation

dotnet nuget add source --name Grandiras --username your_username --password your_token 
dotnet add package --source Grandiras --version 0.1.1 ATProtoNet.Aspire

About this package

Aspire integration for ATProto.NET — registers AtProtoClient as a shared service with health checks, resilience, and OpenTelemetry.

ATProto.NET

CI

A comprehensive .NET SDK for the AT Protocol. Build custom AT Protocol applications with your own Lexicon schemas, or interact with Bluesky — all with clean, modern .NET 10 APIs.

Source: Forgejo (canonical) · GitHub (mirror — issues & PRs welcome here)

⚠️ Disclosure: This repository was mainly created by a coding agent. Thorough testing has been conducted. The maintainer is a human though (me :), Grandiras)

Why ATProto.NET?

The AT Protocol isn't just Bluesky — it's an open protocol where one account works across many apps. Each app defines its own Lexicon schemas and stores records in the user's Personal Data Server (PDS). ATProto.NET makes it easy to build these custom applications in .NET.

Why Version 0.*?

While this library is near to feature complete, it's still vibe-coded for the most part. I don't want to mark anything generated by an agent as "stable" until it is known to be working properly not only by testing, but by actually using it. Furthermore, I still have some things I want to add and improve before considering the API "stable". Therefore, there is no clear timeline for 1.*, but the more this project gets put to the test, the earlier this might happen.

Features

  • Custom Lexicon supportRecordCollection<T> for typed CRUD on your own record schemas
  • Full AT Protocol — authentication, repositories, identity, sync, admin, labels, moderation
  • OAuth authentication — DPoP, PAR, PKCE with dynamic PDS selection
  • Custom XRPC endpoints — call your own query/procedure methods
  • Bluesky APIs — actors, feeds, posts, social graph, notifications, rich text
  • ASP.NET Core integration — dependency injection, JWT authentication handler
  • Blazor components — login forms (with OAuth), profile cards, post cards, feed views, composers
  • Rich text builder — fluent API with automatic UTF-8 byte offset calculation
  • Firehose client — real-time WebSocket streaming
  • Type-safe identityDid, Handle, AtUri, Nsid, Tid, RecordKey, Cid
  • Automatic session management — token refresh, persistence, resume
  • Dynamic PDS — connect to any PDS at runtime, resolve from user identity
  • Lexicon code generator — bidirectional dotnet tool for Lexicon JSON ↔ C# with schema diff
  • Cryptography — P-256 & K-256 key generation, ECDSA signing, did:key / multikey encoding
  • CAR file reader — parse Content Addressable aRchive (CAR v1) files from repo sync
  • PLC directory client — resolve DIDs, fetch operation logs and audit trails
  • Service authentication — generate inter-service JWT tokens for feed generators, labelers, relays

Quick Start

Install

dotnet add package ATProtoNet

Connect & Authenticate

using ATProtoNet;

var client = new AtProtoClientBuilder()
    .WithInstanceUrl("https://your-pds.example.com")
    .Build();

await client.LoginAsync("alice.example.com", "app-password");

For user-facing applications, use AT Protocol OAuth instead of handling passwords:

using ATProtoNet.Auth.OAuth;

// Configure OAuth client
var oauthOptions = new OAuthOptions
{
    ClientMetadata = new OAuthClientMetadata
    {
        ClientId = "https://myapp.example.com/client-metadata.json",
        ClientName = "My App",
        RedirectUris = ["https://myapp.example.com/oauth/callback"],
        Scope = "atproto transition:generic",
        TokenEndpointAuthMethod = "none",
        DpopBoundAccessTokens = true,
    },
};

var oauthClient = new OAuthClient(oauthOptions, httpClient, logger);

// Step 1: Start authorization — works with any handle, DID, or PDS URL
var (authUrl, state) = await oauthClient.StartAuthorizationAsync(
    "alice.bsky.social",
    "https://myapp.example.com/oauth/callback");

// Step 2: Redirect user to authUrl...
// Step 3: Handle callback
var session = await oauthClient.CompleteAuthorizationAsync(code, state, issuer);

// Step 4: Use the session
await client.ApplyOAuthSessionAsync(session);
await client.PostAsync("Hello from OAuth!");

OAuth handles DPoP proof-of-possession, PKCE, server discovery, and identity verification automatically. See the OAuth guide for full details.

Building Custom AT Protocol Apps

The core value of ATProto.NET is enabling you to build your own applications on the AT Protocol. Define your Lexicon record types as C# classes, then use the typed RecordCollection<T> API for full CRUD.

1. Define Your Record Types

using System.Text.Json.Serialization;
using ATProtoNet;

// A simple todo item stored in the user's PDS
public class TodoItem : AtProtoRecord
{
    public override string Type => "com.example.todo.item";

    [JsonPropertyName("title")]
    public string Title { get; set; } = "";

    [JsonPropertyName("completed")]
    public bool Completed { get; set; }

    [JsonPropertyName("priority")]
    public int Priority { get; set; } = 0;

    [JsonPropertyName("dueDate")]
    public string? DueDate { get; set; }
}

AtProtoRecord provides $type and createdAt fields automatically. You can also use any plain C# class — the collection API works with any serializable type.

2. Get a Typed Collection

// Get a strongly-typed collection for your record type
var todos = client.GetCollection<TodoItem>("com.example.todo.item");

3. CRUD Operations

// Create
var created = await todos.CreateAsync(new TodoItem
{
    Title = "Buy groceries",
    Priority = 2,
});
Console.WriteLine($"Created: {created.Uri} (key: {created.RecordKey})");

// Read
var item = await todos.GetAsync(created.RecordKey);
Console.WriteLine($"Title: {item.Value.Title}");

// Update (upsert)
await todos.PutAsync(created.RecordKey, new TodoItem
{
    Title = "Buy groceries",
    Completed = true,
    Priority = 2,
});

// Delete
await todos.DeleteAsync(created.RecordKey);

// Check existence
bool exists = await todos.ExistsAsync(created.RecordKey);

4. List & Paginate

// List with pagination
var page = await todos.ListAsync(limit: 25);
foreach (var record in page.Records)
    Console.WriteLine($"[{(record.Value.Completed ? "x" : " ")}] {record.Value.Title}");

if (page.HasMore)
{
    var nextPage = await todos.ListAsync(limit: 25, cursor: page.Cursor);
}

// Enumerate all records (automatic pagination)
await foreach (var record in todos.EnumerateAsync())
{
    Console.WriteLine($"{record.RecordKey}: {record.Value.Title}");
}

5. Read From Other Users

// Read records from any user's repository
var theirTodos = await todos.ListFromAsync("did:plc:otherperson");

await foreach (var record in todos.EnumerateFromAsync("did:plc:otherperson"))
{
    Console.WriteLine(record.Value.Title);
}

var specificItem = await todos.GetFromAsync("did:plc:otherperson", "3abc");

6. Custom XRPC Endpoints

If your app defines custom query or procedure Lexicon methods (not just record types), call them directly:

// Custom query (HTTP GET)
var result = await client.QueryAsync<SearchResult>(
    "com.example.todo.search",
    new { q = "groceries", limit = 10 });

// Custom procedure (HTTP POST)
var status = await client.ProcedureAsync<BatchResult>(
    "com.example.todo.markAllComplete",
    new { before = "2024-01-01" });

// Fire-and-forget procedure
await client.ProcedureAsync("com.example.todo.cleanup");

Multi-App Example

One AT Protocol account can power many apps — each with its own Lexicons:

await client.LoginAsync("alice.example.com", "app-password");

// Todo app
var todos = client.GetCollection<TodoItem>("com.example.todo.item");

// Bookmark manager
var bookmarks = client.GetCollection<Bookmark>("com.example.bookmarks.bookmark");

// Recipe collection
var recipes = client.GetCollection<Recipe>("com.example.recipes.recipe");

// All stored in the same user's PDS, in separate collections
await todos.CreateAsync(new TodoItem { Title = "Cook dinner" });
await bookmarks.CreateAsync(new Bookmark { Url = "https://example.com", Title = "Example" });
await recipes.CreateAsync(new Recipe { Name = "Pasta", Ingredients = ["pasta", "sauce"] });

Lexicon Code Generator

ATProto.NET includes a bidirectional dotnet tool for working with AT Protocol Lexicon schemas.

Install

dotnet tool install -g ATProtoNet.LexiconGenerator

This installs the atproto-lexgen command globally.

Generate C# from Lexicon JSON

# Generate C# classes from Lexicon schema files
atproto-lexgen csharp --input ./lexicons --output ./Generated --namespace MyApp.Lexicon

Generates sealed class records with required/init properties, [JsonPropertyName] attributes, and $type expression-body properties — matching the ATProto.NET SDK patterns.

Generate Lexicon JSON from C# Assemblies

# Reverse-generate Lexicon schemas from compiled .NET types
atproto-lexgen lexicon --assembly ./bin/MyApp.dll --output ./lexicons

Diff Schemas (Breaking Change Detection)

# Compare two schema directories for breaking changes
atproto-lexgen diff --baseline ./lexicons-v1 --current ./lexicons-v2

# Strict mode — exit code 1 on breaking changes (for CI)
atproto-lexgen diff --baseline ./lexicons-v1 --current ./lexicons-v2 --strict

Detects: added/removed definitions, added/removed properties, type changes, required status changes, and constraint tightening (e.g., maxLength decreased, enum values removed).

Cryptography

AT Protocol cryptographic operations for P-256 and K-256 (secp256k1) curves.

Key Generation & Signing

using ATProtoNet.Crypto;

// Generate a new key pair
using var key = AtProtoCrypto.GenerateKey(KeyCurve.P256);

// Sign and verify
byte[] data = "Hello AT Protocol"u8.ToArray();
byte[] signature = key.Sign(data);
bool valid = key.Verify(data, signature);

// Export/import private key (PKCS#8)
byte[] privateKey = key.ExportPrivateKey();
using var restored = AtProtoKey.ImportPrivateKey(privateKey, KeyCurve.P256);

DID Key & Multikey

// Convert to did:key
string didKey = key.ToDidKey();    // "did:key:zDnae..."
string multikey = key.ToMultikey(); // "zDnae..."

// Parse did:key back to a verification key
using var parsed = AtProtoCrypto.FromDidKey(didKey);

// Compressed public key
byte[] compressed = key.GetCompressedPublicKey();

CAR File Parsing

Parse CAR v1 (Content Addressable aRchive) files, used by com.atproto.sync.getRepo.

using ATProtoNet.Repo;

// Parse from a stream (e.g., HTTP response)
var car = CarReader.ReadFromStream(stream);

// Access header and roots
Console.WriteLine($"Version: {car.Header.Version}");
foreach (var root in car.Header.Roots)
    Console.WriteLine($"Root CID: {root}");

// Iterate blocks
foreach (var block in car.Blocks)
    Console.WriteLine($"CID: {block.Cid}, Size: {block.Data.Length}");

// Lookup by CID
var block = car.GetBlock("bafyrei...");

PLC Directory

Resolve DIDs and query the PLC directory.

using ATProtoNet.Identity;

var plc = new PlcClient(httpClient);

// Resolve a DID to its document
var doc = await plc.ResolveDidAsync("did:plc:z72i7hdynmk6r22z27h6tvur");
Console.WriteLine($"Handle: {doc.GetHandle()}");
Console.WriteLine($"PDS: {doc.GetPdsEndpoint()}");

// Operation and audit logs
var ops = await plc.GetOperationLogAsync("did:plc:z72i7hdynmk6r22z27h6tvur");
var audit = await plc.GetAuditLogAsync("did:plc:z72i7hdynmk6r22z27h6tvur");

// Health check
bool healthy = await plc.IsHealthyAsync();

Service Authentication

Generate inter-service JWT tokens for feed generators, labelers, and relay services.

using ATProtoNet.Auth;
using ATProtoNet.Crypto;

using var signingKey = AtProtoCrypto.GenerateKey(KeyCurve.P256);
var authGen = new ServiceAuthGenerator("did:plc:myservice", signingKey);

// Generate a service auth token (default 60s expiry)
string jwt = await authGen.GenerateTokenAsync(
    serviceDid: "did:plc:myservice",
    audience: "did:web:feed.example.com",
    lxm: "app.bsky.feed.getFeedSkeleton");

The token includes iss, aud, exp, iat, jti, and optional lxm claims. Maximum expiry is enforced at 5 minutes per the AT Protocol spec.

Bluesky Integration

ATProto.NET also provides full Bluesky application support:

Create a Post

await client.PostAsync("Hello from ATProto.NET!");

Rich Text

using ATProtoNet.Lexicon.App.Bsky.RichText;

var (text, facets) = new RichTextBuilder()
    .Text("Check out ")
    .Link("ATProto.NET", "https://github.com/example/ATProto.NET")
    .Text(" — built with ")
    .Tag("atproto")
    .Text("!")
    .Build();

await client.PostAsync(text, facets: facets);

Profiles & Feeds

var profile = await client.Bsky.Actor.GetProfileAsync("alice.bsky.social");
Console.WriteLine($"{profile.DisplayName} — {profile.Description}");

var timeline = await client.Bsky.Feed.GetTimelineAsync(limit: 25);
foreach (var item in timeline.Feed!)
    Console.WriteLine($"@{item.Post!.Author!.Handle}: {item.Post.Record}");

Social Actions

await client.FollowAsync("did:plc:abc123");
await client.LikeAsync("at://did:plc:abc/app.bsky.feed.post/3k2la", "bafyreib...");
await client.RepostAsync("at://did:plc:abc/app.bsky.feed.post/3k2la", "bafyreib...");

ASP.NET Core Integration

Standalone Client (Server-to-Server)

For bot or service scenarios with app-password authentication:

builder.Services.AddAtProto(options =>
{
    options.InstanceUrl = "https://bsky.social";
});

User-Authenticated Access (with Blazor OAuth)

For apps where users log in via OAuth and the backend accesses AT Proto on their behalf:

// Program.cs
builder.Services.AddAuthentication("Cookies").AddCookie();
builder.Services.AddAtProtoAuthentication();  // Blazor OAuth login
builder.Services.AddAtProtoServer();           // Backend AT Proto access

app.MapAtProtoOAuth();

Then use IAtProtoClientFactory in API endpoints or Blazor components:

// Minimal API endpoint
app.MapGet("/api/profile", async (ClaimsPrincipal user, IAtProtoClientFactory factory) =>
{
    await using var client = await factory.CreateClientForUserAsync(user);
    if (client is null) return Results.Unauthorized();
    var profile = await client.Bsky.Actor.GetProfileAsync(client.Session!.Did);
    return Results.Ok(new { profile.DisplayName, profile.Handle });
}).RequireAuthorization();
@* Or in a Blazor component *@
@inject IAtProtoClientFactory ClientFactory

@code {
    [CascadingParameter] Task<AuthenticationState> AuthState { get; set; } = null!;

    protected override async Task OnInitializedAsync()
    {
        var auth = await AuthState;
        await using var client = await ClientFactory.CreateClientForUserAsync(auth.User);
        // Use client to call AT Proto APIs...
    }
}

See the ServerIntegrationSample for a complete example.

Blazor Integration

Setup

// Program.cs
builder.Services.AddAuthentication("Cookies").AddCookie("Cookies");
builder.Services.AddAtProtoAuthentication();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapAtProtoOAuth();   // Maps /atproto/login, /atproto/callback, /atproto/logout

Login Component

@using ATProtoNet.Blazor.Components

<LoginForm ReturnUrl="/" />

Auth State

<AuthorizeView>
    <Authorized>
        Signed in as @context.User.FindFirst("handle")?.Value
        <form action="/atproto/logout" method="post">
            <button type="submit">Logout</button>
        </form>
    </Authorized>
    <NotAuthorized>
        <a href="/login">Sign in</a>
    </NotAuthorized>
</AuthorizeView>

See docs/blazor.md for full documentation including custom claims, configuration, and production setup.

Architecture

ATProto.NET/
├── src/
│   ├── ATProtoNet/                    # Core SDK
│   │   ├── Identity/                  # Did, Handle, AtUri, Nsid, Tid, PlcClient
│   │   ├── Auth/                      # Session, ISessionStore, ServiceAuthGenerator
│   │   │   └── OAuth/                 # OAuth client, DPoP, PKCE, discovery
│   │   ├── Crypto/                    # AtProtoCrypto, AtProtoKey (P-256/K-256)
│   │   ├── Http/                      # XrpcClient, AtProtoHttpException
│   │   ├── Models/                    # BlobRef, StrongRef, Label, etc.
│   │   ├── Repo/                      # CarReader (CAR v1 parsing)
│   │   ├── Serialization/             # JSON converters, defaults
│   │   ├── Streaming/                 # FirehoseClient
│   │   ├── RecordCollection.cs        # Typed collection CRUD for custom records
│   │   ├── AtProtoClient.cs           # Main client facade
│   │   └── Lexicon/
│   │       ├── Com/AtProto/           # Protocol-level APIs
│   │       │   ├── Server/            # Authentication, session management
│   │       │   ├── Repo/              # Record CRUD, blob upload
│   │       │   ├── Identity/          # Handle/DID resolution
│   │       │   ├── Sync/              # Repo sync, blob download
│   │       │   ├── Admin/             # Admin operations
│   │       │   ├── Label/             # Content labels
│   │       │   └── Moderation/        # Moderation reports
│   │       └── App/Bsky/              # Bluesky app APIs
│   │           ├── Actor/             # Profiles, preferences
│   │           ├── Feed/              # Posts, timeline, feeds
│   │           ├── Graph/             # Follows, blocks, mutes, lists
│   │           ├── Notification/      # Notifications
│   │           ├── RichText/          # Rich text builder, facets
│   │           └── Embed/             # Images, links, quotes, video
│   ├── ATProtoNet.Server/            # ASP.NET Core integration
│   │   ├── Extensions/               # DI registration (AddAtProtoServer, AddAtProto)
│   │   ├── Authentication/           # JWT auth handler
│   │   ├── Services/                 # IAtProtoClientFactory
│   │   └── TokenStore/               # IAtProtoTokenStore, FileAtProtoTokenStore (default), InMemoryAtProtoTokenStore
│   └── ATProtoNet.Blazor/            # Blazor components
│       ├── Components/                # Razor components (LoginForm)
│       ├── Authentication/            # OAuth service, options
│       └── Extensions/               # DI registration (AddAtProtoAuthentication)
├── tools/
│   └── ATProtoNet.LexiconGenerator/   # Lexicon ↔ C# code generator (dotnet tool)
│       ├── CodeGen/                   # Generators, LexiconDiffer
│       └── Models/                    # Lexicon schema models
├── samples/
│   ├── BlazorOAuthSample/            # Blazor Server OAuth example
│   └── ServerIntegrationSample/      # Blazor + server-side AT Proto access
└── tests/
    ├── ATProtoNet.Tests/              # Unit tests (451 tests)
    └── ATProtoNet.IntegrationTests/   # Integration tests (requires PDS)

API Reference

Client Namespaces

Property Namespace Description
client.GetCollection<T>() Typed CRUD for custom records
client.QueryAsync<T>() Custom XRPC queries
client.ProcedureAsync<T>() Custom XRPC procedures
client.Server com.atproto.server.* Authentication, sessions, app passwords
client.Repo com.atproto.repo.* Low-level record CRUD, blob upload, batch writes
client.Identity com.atproto.identity.* Handle/DID resolution
client.Sync com.atproto.sync.* Repo sync, blob download
client.Admin com.atproto.admin.* Admin operations
client.Label com.atproto.label.* Content label queries
client.Moderation com.atproto.moderation.* Moderation reports
client.Bsky.Actor app.bsky.actor.* Profile read/write, search
client.Bsky.Feed app.bsky.feed.* Posts, timeline, feeds, likes
client.Bsky.Graph app.bsky.graph.* Follows, blocks, mutes, lists
client.Bsky.Notification app.bsky.notification.* Notification management

Additional Components

Class Namespace Description
AtProtoCrypto ATProtoNet.Crypto P-256/K-256 key generation, signing, did:key/multikey
AtProtoKey ATProtoNet.Crypto Key pair wrapper: sign, verify, export/import
CarReader ATProtoNet.Repo CAR v1 file parsing (repo sync)
PlcClient ATProtoNet.Identity PLC directory resolution and queries
ServiceAuthGenerator ATProtoNet.Auth Inter-service JWT generation

Identity Types

var did = Did.Parse("did:plc:z72i7hdynmk6r22z27h6tvur");
var handle = Handle.Parse("alice.example.com");
var uri = AtUri.Parse("at://did:plc:abc/com.example.todo.item/3k2la");
var nsid = Nsid.Parse("com.example.todo.item");
var tid = Tid.Next();
var rkey = RecordKey.Parse("self");
var id = AtIdentifier.Parse("did:plc:abc"); // or "alice.example.com"

Custom Session Persistence

public class FileSessionStore : ISessionStore
{
    private readonly string _path;
    public FileSessionStore(string path) => _path = path;

    public async Task SaveAsync(Session session, CancellationToken ct = default)
    {
        var json = JsonSerializer.Serialize(session);
        await File.WriteAllTextAsync(_path, json, ct);
    }

    public async Task<Session?> LoadAsync(CancellationToken ct = default)
    {
        if (!File.Exists(_path)) return null;
        var json = await File.ReadAllTextAsync(_path, ct);
        return JsonSerializer.Deserialize<Session>(json);
    }

    public Task ClearAsync(CancellationToken ct = default)
    {
        if (File.Exists(_path)) File.Delete(_path);
        return Task.CompletedTask;
    }
}

var client = new AtProtoClientBuilder()
    .WithInstanceUrl("https://your-pds.example.com")
    .WithSessionStore(new FileSessionStore("session.json"))
    .Build();

Firehose (Real-time Streaming)

using ATProtoNet.Streaming;

var firehose = new FirehoseClient("wss://bsky.network");

await foreach (var message in firehose.SubscribeAsync())
{
    Console.WriteLine($"Seq: {message.Seq}, Repo: {message.Repo}");
}

Running Tests

Unit Tests

dotnet test tests/ATProtoNet.Tests

Integration Tests

Integration tests require a running PDS. Set environment variables and run:

export ATPROTO_PDS_URL=http://localhost:2583
export ATPROTO_TEST_HANDLE=test.handle
export ATPROTO_TEST_PASSWORD=your-password

dotnet test tests/ATProtoNet.IntegrationTests

Local PDS with Podman/Docker

podman run -d \
  --name pds \
  -p 2583:3000 \
  -e PDS_HOSTNAME=localhost \
  -e PDS_JWT_SECRET=$(openssl rand -hex 16) \
  -e PDS_ADMIN_PASSWORD=admin \
  -e PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$(openssl rand -hex 32) \
  -e PDS_DATA_DIRECTORY=/pds \
  -e PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks \
  -e PDS_DID_PLC_URL=https://plc.directory \
  ghcr.io/bluesky-social/pds:latest

Requirements

  • .NET 10.0 SDK or later
  • For ASP.NET Core: Microsoft.AspNetCore.App framework reference
  • For Blazor: Microsoft.AspNetCore.Components.Web 10.0+

License

MIT

Dependencies

ID Version Target Framework
ATProtoNet 0.1.1 net10.0
Microsoft.Extensions.Configuration.Binder 10.0.0 net10.0
Microsoft.Extensions.Diagnostics.HealthChecks 10.0.0 net10.0
Microsoft.Extensions.Hosting.Abstractions 10.0.0 net10.0
Microsoft.Extensions.Http.Resilience 10.0.0 net10.0
Details
NuGet
2026-04-12 22:21:08 +00:00
1
Grandiras
20 KiB
Assets (2)
Versions (1) View all
0.1.1 2026-04-12