ATProtoNet.Server (0.2.0)

Published 2026-02-20 22:32:35 +00:00 by Grandiras in Grandiras/ATProto.NET

Installation

dotnet nuget add source --name Grandiras --username your_username --password your_token 
dotnet add package --source Grandiras --version 0.2.0 ATProtoNet.Server

About this package

ASP.NET Core integration for ATProtoNet — dependency injection, authentication, and middleware for AT Protocol applications.

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.

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

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"] });

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

Register Services

// Program.cs
builder.Services.AddAtProto(options =>
{
    options.InstanceUrl = "https://your-pds.example.com";
});

// Or scoped (per-request) clients
builder.Services.AddAtProtoScoped(options =>
{
    options.InstanceUrl = "https://your-pds.example.com";
});

AT Protocol Authentication

builder.Services.AddAuthentication()
    .AddScheme<AtProtoAuthenticationOptions, AtProtoAuthenticationHandler>(
        "AtProto", options =>
        {
            options.PdsUrl = "https://your-pds.example.com";
        });

Use in Controllers

[ApiController]
[Route("api/todos")]
public class TodoController : ControllerBase
{
    private readonly AtProtoClient _client;

    public TodoController(AtProtoClient client) => _client = client;

    [HttpGet]
    public async Task<IActionResult> ListTodos()
    {
        var todos = _client.GetCollection<TodoItem>("com.example.todo.item");
        var page = await todos.ListAsync(limit: 50);
        return Ok(page.Records.Select(r => r.Value));
    }

    [HttpPost]
    public async Task<IActionResult> CreateTodo([FromBody] TodoItem item)
    {
        var todos = _client.GetCollection<TodoItem>("com.example.todo.item");
        var created = await todos.CreateAsync(item);
        return Created(created.Uri, item);
    }
}

Blazor Integration

Register Services

// Program.cs — without OAuth
builder.Services.AddAtProtoBlazor();

// With OAuth
builder.Services.AddAtProtoBlazor(options =>
{
    options.InstanceUrl = "https://bsky.social";
    options.OAuth = new OAuthOptions
    {
        ClientMetadata = new OAuthClientMetadata
        {
            ClientId = "https://myapp.example.com/client-metadata.json",
            // ...
        },
    };
});

Components

@using ATProtoNet.Blazor.Components

@* Login with OAuth support *@
<AtProtoLoginForm 
    OnLoginSuccess="HandleLogin"
    OAuthRedirectUri="https://myapp.example.com/oauth/callback"
    PreferOAuth="true" />

@* OAuth callback page *@
<OAuthCallback />

@* Other components *@
<AtProtoProfileCard Actor="@did" />
<AtProtoFeedView />
<AtProtoComposePost OnPostCreated="HandlePost" />
<AtProtoPostCard Post="@post" />

Architecture

ATProto.NET/
├── src/
│   ├── ATProtoNet/                    # Core SDK
│   │   ├── Identity/                  # Did, Handle, AtUri, Nsid, Tid, etc.
│   │   ├── Auth/                      # Session, ISessionStore
│   │   │   └── OAuth/                 # OAuth client, DPoP, PKCE, discovery
│   │   ├── Http/                      # XrpcClient, AtProtoHttpException
│   │   ├── Models/                    # BlobRef, StrongRef, Label, etc.
│   │   ├── 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
│   │   └── Authentication/           # JWT auth handler
│   └── ATProtoNet.Blazor/            # Blazor components
│       ├── Components/                # Razor components (login, OAuth callback)
│       ├── Services/                  # Auth state provider (OAuth-aware)
│       └── Extensions/               # DI registration
├── samples/
│   └── BlazorOAuthSample/            # Blazor Server OAuth example
└── tests/
    ├── ATProtoNet.Tests/              # Unit tests (268 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

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.2.0 net10.0
Details
NuGet
2026-02-20 22:32:35 +00:00
0
Grandiras
16 KiB
Assets (2)
Versions (4) View all
0.3.0 2026-02-21
0.2.0 2026-02-20
0.1.1 2026-02-20
0.1.0 2026-02-19