Skip to content

Commit 6ae980d

Browse files
committed
feat: Enhance ActivityPub compliance with Move activity handling and featured collection support; add integration tests for actor document and migration scenarios
1 parent f4166ad commit 6ae980d

6 files changed

Lines changed: 492 additions & 20 deletions

File tree

README.md

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,104 @@ A modular .NET library for building ActivityPub-enabled applications.
66

77
Broca provides a complete, standards-compliant implementation of the ActivityPub protocol for .NET developers. It offers both client and server capabilities with a clean API that makes federation simple.
88

9-
**Key Features:**
10-
- ✅ Full ActivityPub client and server implementation
11-
- ✅ HTTP Signatures for authenticated federation
9+
## Features
10+
11+
### Core ActivityPub Protocol
12+
13+
**Client-to-Server (C2S)**
14+
- ✅ Post activities to outbox (Create, Like, Follow, Announce, Undo)
15+
- ✅ Server-assigned activity IDs with proper URL structure
16+
- ✅ Follow/Unfollow relationship management
17+
- ✅ HTTP Signature authentication for outbox operations
18+
- ✅ API key-based client authentication
19+
20+
**Server-to-Server (S2S) Federation**
21+
- ✅ Cross-server activity delivery with HTTP Signatures
22+
- ✅ Background delivery queue with retry logic
23+
- ✅ Follow/Accept/Reject workflow (auto-accept and manual approval modes)
24+
- ✅ Undo operations (Follow, Like, Announce)
25+
- ✅ Delete activities with Tombstone support
26+
- ✅ Update Person for profile changes
27+
- ✅ Move activity for account migration with `alsoKnownAs` validation
28+
- ✅ Date validation (reject stale or future-dated requests)
29+
- ✅ Actor caching and refresh
30+
31+
**Shared Inbox**
32+
- ✅ Efficient batch delivery to multiple local users
33+
- ✅ To, Cc, and Bcc addressing support
34+
- ✅ Public addressing (`https://www.w3.org/ns/activitystreams#Public`)
35+
- ✅ Followers collection addressing
36+
- ✅ Smart routing (mixed local/remote recipient handling)
37+
38+
### Collections & Discovery
39+
40+
- ✅ Followers and Following collections
41+
- ✅ Custom collections (manual curation and query-based)
42+
- ✅ Ordered collection pagination
1243
- ✅ WebFinger support for @user@domain discovery
13-
- ✅ Background activity delivery with retry logic
14-
- ✅ Modular persistence (in-memory, file-based, or custom)
15-
- ✅ Production-ready with Docker and Let's Encrypt support
44+
- ✅ NodeInfo 2.0 and 2.1 (server metadata and statistics)
45+
46+
### Media & Attachments
47+
48+
- ✅ Blob storage for media files
49+
- ✅ Media endpoint (`/users/:username/media/:blobId`)
50+
- ✅ Attachments in Create activities
51+
- ✅ Content-Type preservation and validation
52+
53+
### Security & Authentication
54+
55+
- ✅ HTTP Signatures (draft-cavage-http-signatures-12)
56+
- ✅ Public key infrastructure with cryptographic key generation
57+
- ✅ Request date validation (prevents replay attacks)
58+
- ✅ Outbox authentication (users can only post as themselves)
59+
- ✅ Signature verification for incoming federation
60+
61+
### Content Type Handling
62+
63+
-`application/activity+json`
64+
-`application/ld+json`
65+
- ✅ Profile parameter support (`application/ld+json; profile="..."`)
66+
- ✅ Mastodon compatibility
67+
68+
### Administration
69+
70+
- ✅ Back-channel user management via ActivityPub protocol
71+
- ✅ System actor for server-level operations
72+
- ✅ Create/Update/Delete users through admin endpoints
73+
- ✅ Automatic key pair generation for new actors
74+
75+
### Persistence & Storage
76+
77+
- ✅ Modular storage abstractions (`IActorRepository`, `IActivityRepository`, `IBlobStorageService`)
78+
- ✅ In-memory implementation (development/testing)
79+
- ✅ File-based implementation (production-ready)
80+
- ✅ Easy integration with custom storage backends
81+
82+
### Developer Experience
83+
84+
- ✅ Comprehensive integration test suite (88+ tests)
85+
- ✅ Multi-server federation testing infrastructure
86+
- ✅ Activity builder API for constructing valid ActivityStreams objects
87+
- ✅ Blazor component library for building federated UIs
88+
- ✅ Docker and Let's Encrypt support for production deployment
89+
90+
## Standards Compliance
91+
92+
Broca implements the core [ActivityPub W3C Recommendation](https://www.w3.org/TR/activitypub/) with extensions for real-world interoperability:
93+
94+
-**ActivityPub** - Client-to-Server and Server-to-Server protocols
95+
-**ActivityStreams 2.0** - Core vocabulary and extended types
96+
-**HTTP Signatures** - Request authentication (draft-cavage-http-signatures-12)
97+
-**WebFinger** (RFC 7033) - User discovery via @username@domain
98+
-**NodeInfo 2.0/2.1** - Server metadata and statistics
99+
100+
**Interoperability Target:** Mastodon (primary), with support for Threads, Pixelfed, and Pleroma.
101+
102+
**Known Limitations:**
103+
- `featured` collection not yet exposed on actor documents (pinned posts)
104+
- Follow/Following collections for locked accounts publicly visible (privacy enhancement pending)
105+
106+
All critical and high-priority federation features are complete. See [docs/s2s-compliance-todo.md](docs/s2s-compliance-todo.md) for detailed compliance tracking.
16107

17108
## Library Components
18109

@@ -309,6 +400,29 @@ builder.Services.AddSingleton<IIdentityProvider, CustomIdentityProvider>();
309400
- Docker (for containerized deployment)
310401
- A domain name with HTTPS for production federation
311402

403+
## Testing & Quality Assurance
404+
405+
Broca includes a comprehensive test suite to ensure reliability and standards compliance:
406+
407+
**Integration Tests** (88+ tests across 10 test suites)
408+
- `ServerToServerTests` - Cross-server federation scenarios (22 tests)
409+
- `SharedInboxTests` - Efficient multi-user delivery (11 tests)
410+
- `ClientToServerTests` - Outbox posting and activity creation (7 tests)
411+
- `CustomCollectionsTests` - Manual and query-based collections (15 tests)
412+
- `AdminOperationsTests` - Back-channel user management (8 tests)
413+
- `ClientAuthenticationTests` - API key and HTTP Signature auth (5 tests)
414+
- `ContentTypeHandlingTests` - Mastodon compatibility (7 tests)
415+
- `BlobStorageTests` - Media upload and retrieval (4 tests)
416+
- `NodeInfoStatisticsTests` - Server metadata endpoints (5 tests)
417+
- `OutboxAuthenticationTests` - Security validation (3 tests)
418+
419+
**Unit Tests**
420+
- Repository implementations (actors, activities, delivery queue)
421+
- Blob storage service functionality
422+
- Component rendering logic
423+
424+
All tests use real HTTP clients and in-memory servers to validate end-to-end behavior, not mocked implementations. This ensures that Broca works correctly with actual ActivityPub clients and servers in the fediverse.
425+
312426
## Contributing
313427

314428
Contributions are welcome! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for development workflow and guidelines.

docs/s2s-compliance-todo.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,21 @@ Last reviewed: 2026-02-28. Mastodon is the primary target for interop, with seco
88

99
**Critical items:** 0 remaining (all resolved)
1010
**High priority items:** 0 remaining (all resolved)
11-
**Medium priority items:** 5 remaining
11+
**Medium priority items:** 3 remaining
1212
**Low priority items:** 2 remaining
1313

1414

1515
## 🟡 Medium — Missing Features / Spec Gaps
1616

17-
### M1 · `Move` activity not handled (account migration)
17+
### M1 · `Move` activity handled (COMPLETED)
1818

1919
**File:** `InboxProcessor.cs`
2020

21-
`Move` is used by Mastodon for account portability. Currently falls to `_ => true` (accepted but not processed).
21+
`Move` is used by Mastodon for account portability. When a user migrates to a new account, followers are automatically migrated.
2222

23-
**Fix:** Handle `Move` by updating the follower's following list: replace the old actor IRI with the new one (if the new actor's `alsoKnownAs` references the old one, to prevent spoofing).
23+
**Implementation:** Handles `Move` by updating the follower's following list: replaces the old actor IRI with the new one if the new actor's `alsoKnownAs` references the old one (prevents spoofing).
2424

25-
**Priority:** Medium - required for full Mastodon compatibility, but low usage.
25+
**Status:** ✅ Implemented and tested with security validation.
2626

2727
---
2828

@@ -38,15 +38,15 @@ Mastodon expects a `featured` property on the actor document pointing to an `Ord
3838

3939
---
4040

41-
### M3 · Actor document missing `alsoKnownAs` field
41+
### M3 · `alsoKnownAs` field supported (COMPLETED)
4242

43-
**File:** `ActorController.cs``Get`
43+
**File:** `ActorController.cs`
4444

45-
Required for `Move`-based account migration. Actors need to be able to declare aliases.
45+
Required for `Move`-based account migration. Actors can declare aliases via the `alsoKnownAs` property.
4646

47-
**Fix:** Support an `alsoKnownAs` property in the actor's stored extension data and expose it in the actor document response.
47+
**Implementation:** Supported via actor's ExtensionData dictionary. Set as a JSON array of actor URI strings.
4848

49-
**Priority:** Medium - required for M1 (Move activity) to be fully functional.
49+
**Status:** ✅ No code changes required - ExtensionData already serializes arbitrary fields in actor documents.
5050

5151
---
5252

@@ -80,9 +80,9 @@ The outer `Split(',')` then inner `Split('=', 2)` approach is correct for most c
8080

8181
| Order | Item | Effort | Notes |
8282
|-------|------|--------|-------|
83-
| 1 | M2`featured` collection on actor | Small | Quick win — collection infrastructure exists |
84-
| 2 | M3 — `alsoKnownAs` | Small | Required for M1 |
85-
| 3 | M1`Move` activity | Medium | Depends on M3 |
83+
| 1 | ~~M1`Move` activity~~ | ~~Medium~~ | **Completed** |
84+
| 2 | ~~M3 — `alsoKnownAs`~~ | ~~Small~~ | **Completed** (no code changes needed) |
85+
| 3 | M2`featured` collection on actor | Small | Quick win — collection infrastructure exists |
8686
| 4 | M4 — Followers list auth for locked accounts | Small | Privacy fix |
8787
| 5 | M5 — NodeInfo real stats | Medium | Requires repo changes |
8888
| 6 | L2 — Content-type testing | Small | Verification only |

src/Broca.ActivityPub.Server/Controllers/ActorController.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,18 @@ public async Task<IActionResult> Get(string username)
9393
$"{baseUrl}/users/{username}/collections",
9494
_jsonOptions);
9595

96-
// Add individual collection links with broca: prefix
96+
// Special case: "featured" is a de facto standard across the fediverse (Mastodon, Pleroma, etc.)
97+
// for pinned posts. Expose it at root level for interoperability.
98+
// This is a one-off exception - all other collections use broca: prefix.
99+
var featuredCollection = publicCollections.FirstOrDefault(c => c.Id == "featured");
100+
if (featuredCollection != null)
101+
{
102+
actor.ExtensionData["featured"] = JsonSerializer.SerializeToElement(
103+
$"{baseUrl}/users/{username}/collections/featured",
104+
_jsonOptions);
105+
}
106+
107+
// Add individual collection links with broca: prefix (for Broca-specific extensions)
97108
foreach (var collection in publicCollections)
98109
{
99110
actor.ExtensionData[$"broca:{collection.Id}"] = JsonSerializer.SerializeToElement(

src/Broca.ActivityPub.Server/Services/InboxProcessor.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public class InboxProcessor : IInboxHandler
1919
private readonly AdminOperationsHandler _adminOperationsHandler;
2020
private readonly AttachmentProcessingService _attachmentProcessingService;
2121
private readonly ActivityDeliveryService _deliveryService;
22+
private readonly IActivityPubClient _activityPubClient;
2223
private readonly ActivityPubServerOptions _options;
2324
private readonly ILogger<InboxProcessor> _logger;
2425

@@ -30,6 +31,7 @@ public InboxProcessor(
3031
AdminOperationsHandler adminOperationsHandler,
3132
AttachmentProcessingService attachmentProcessingService,
3233
ActivityDeliveryService deliveryService,
34+
IActivityPubClient activityPubClient,
3335
IOptions<ActivityPubServerOptions> options,
3436
ILogger<InboxProcessor> logger)
3537
{
@@ -40,6 +42,7 @@ public InboxProcessor(
4042
_adminOperationsHandler = adminOperationsHandler;
4143
_attachmentProcessingService = attachmentProcessingService;
4244
_deliveryService = deliveryService;
45+
_activityPubClient = activityPubClient;
4346
_options = options.Value;
4447
_logger = logger;
4548
}
@@ -102,6 +105,7 @@ public async Task<bool> HandleActivityAsync(string username, IObjectOrLink activ
102105
"Add" => await HandleAddAsync(username, activity as IObject, cancellationToken),
103106
"Remove" => await HandleRemoveAsync(username, activity as IObject, cancellationToken),
104107
"Update" => await HandleIncomingUpdateAsync(username, activity as IObject, cancellationToken),
108+
"Move" => await HandleMoveAsync(username, activity as IObject, cancellationToken),
105109
_ => true // Unknown types are accepted but not processed
106110
};
107111

@@ -357,6 +361,111 @@ private Task<bool> HandleAnnounceAsync(string username, IObject? activity, Cance
357361
return Task.FromResult(true);
358362
}
359363

364+
private async Task<bool> HandleMoveAsync(string username, IObject? activity, CancellationToken cancellationToken)
365+
{
366+
if (activity is not Activity moveActivity || moveActivity.Object == null || moveActivity.Target == null)
367+
{
368+
_logger.LogWarning("Move activity missing required properties");
369+
return false;
370+
}
371+
372+
// Extract old actor ID (from object - the actor being moved from)
373+
var oldActorRef = moveActivity.Object.FirstOrDefault();
374+
var oldActorId = oldActorRef switch
375+
{
376+
ILink link => link.Href?.ToString(),
377+
IObject obj => obj.Id,
378+
_ => null
379+
};
380+
381+
if (string.IsNullOrEmpty(oldActorId))
382+
{
383+
_logger.LogWarning("Move activity has invalid object reference");
384+
return false;
385+
}
386+
387+
// Extract new actor ID (from target - the actor being moved to)
388+
var newActorRef = moveActivity.Target.FirstOrDefault();
389+
var newActorId = newActorRef switch
390+
{
391+
ILink link => link.Href?.ToString(),
392+
IObject obj => obj.Id,
393+
_ => null
394+
};
395+
396+
if (string.IsNullOrEmpty(newActorId))
397+
{
398+
_logger.LogWarning("Move activity has invalid target reference");
399+
return false;
400+
}
401+
402+
_logger.LogInformation("Processing Move activity: {OldActorId} -> {NewActorId} for user {Username}",
403+
oldActorId, newActorId, username);
404+
405+
// Check if this user follows the old actor
406+
var following = await _actorRepository.GetFollowingAsync(username, cancellationToken);
407+
if (!following.Contains(oldActorId))
408+
{
409+
_logger.LogDebug("User {Username} does not follow {OldActorId}, skipping Move", username, oldActorId);
410+
return true; // Not an error, just not relevant to this user
411+
}
412+
413+
// Fetch the new actor to verify alsoKnownAs
414+
try
415+
{
416+
var newActor = await _activityPubClient.GetAsync<Actor>(new Uri(newActorId), useCache: false, cancellationToken);
417+
418+
if (newActor == null)
419+
{
420+
_logger.LogWarning("Could not fetch new actor {NewActorId} for Move verification", newActorId);
421+
return false;
422+
}
423+
424+
// Security check: verify alsoKnownAs contains the old actor ID
425+
var alsoKnownAs = new List<string>();
426+
if (newActor.ExtensionData?.TryGetValue("alsoKnownAs", out var alsoKnownAsElement) == true)
427+
{
428+
try
429+
{
430+
if (alsoKnownAsElement.ValueKind == JsonValueKind.Array)
431+
{
432+
alsoKnownAs = JsonSerializer.Deserialize<List<string>>(alsoKnownAsElement.GetRawText()) ?? new List<string>();
433+
}
434+
}
435+
catch (JsonException ex)
436+
{
437+
_logger.LogWarning(ex, "Failed to parse alsoKnownAs for {NewActorId}", newActorId);
438+
}
439+
}
440+
441+
if (!alsoKnownAs.Contains(oldActorId))
442+
{
443+
_logger.LogWarning("Security check failed: {NewActorId} does not list {OldActorId} in alsoKnownAs. Rejecting Move.",
444+
newActorId, oldActorId);
445+
return false;
446+
}
447+
448+
// Valid migration - update following list
449+
await _actorRepository.RemoveFollowingAsync(username, oldActorId, cancellationToken);
450+
await _actorRepository.AddFollowingAsync(username, newActorId, cancellationToken);
451+
452+
_logger.LogInformation("Successfully migrated follow for {Username}: {OldActorId} -> {NewActorId}",
453+
username, oldActorId, newActorId);
454+
455+
return true;
456+
}
457+
catch (HttpRequestException ex)
458+
{
459+
_logger.LogError(ex, "Network error fetching new actor {NewActorId} for Move verification", newActorId);
460+
return false;
461+
}
462+
catch (Exception ex)
463+
{
464+
_logger.LogError(ex, "Error processing Move activity for {Username}", username);
465+
return false;
466+
}
467+
}
468+
360469
private async Task<bool> HandleAddAsync(string username, IObject? activity, CancellationToken cancellationToken)
361470
{
362471
if (activity is not Activity addActivity || addActivity.Object == null || addActivity.Target == null)

0 commit comments

Comments
 (0)