Federation PRD: Cross-Instance Posting (Beta)#
Status: Planning - Beta Target: Beta Release Owner: TBD Last Updated: 2025-11-16
Overview#
Enable Lemmy-style federation where users on any Coves instance can post to communities hosted on other instances, while maintaining community ownership and moderation control.
Problem Statement#
Current (Alpha):
- Posts to communities require community credentials
- Users can only post to communities on their home instance
- No true federation across instances
Desired (Beta):
- User A@coves.social can post to !gaming@covesinstance.com
- Communities maintain full moderation control
- Content lives in community repositories (not user repos)
- Seamless UX - users don't think about federation
Goals#
Primary Goals#
- Enable cross-instance posting - Users can post to any community on any federated instance
- Preserve community ownership - Posts live in community repos, not user repos
- atProto-native implementation - Use
com.atproto.server.getServiceAuthpattern - Maintain security - No compromise on auth, validation, or moderation
Non-Goals (Future Versions)#
- Automatic instance discovery (Beta: manual allowlist)
- Cross-instance moderation delegation
- Content mirroring/replication
- User migration between instances
Technical Approach#
Architecture: atProto Service Auth#
Use atProto's native service authentication delegation pattern:
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ User A │ │ coves.social │ │ covesinstance│
│ @coves.soc │────────▶│ AppView │────────▶│ .com PDS │
└─────────────┘ (1) └──────────────────┘ (2) └─────────────┘
JWT auth Request Service Auth Validate
│ │
│◀──────────────────────┘
│ (3) Scoped Token
│
▼
┌──────────────────┐
│ covesinstance │
│ .com PDS │
│ Write Post │
└──────────────────┘
│
▼
┌──────────────────┐
│ Firehose │
│ (broadcasts) │
└──────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ coves.social │ │covesinstance │
│ AppView │ │ .com AppView│
│ (indexes) │ │ (indexes) │
└──────────────┘ └──────────────┘
Flow Breakdown#
Step 1: User Authentication (Unchanged)
- User authenticates with their home instance (coves.social)
- Receives JWT token for API requests
Step 2: Service Auth Request (New)
- When posting to remote community, AppView requests service auth token
- Endpoint:
POST {remote-pds}/xrpc/com.atproto.server.getServiceAuth - Payload:
{ "aud": "did:plc:community123", // Community DID "exp": 1234567890, // Token expiration "lxm": "social.coves.community.post.create" // Authorized method }
Step 3: Service Auth Validation (New - PDS Side)
- Remote PDS validates request:
- Is requesting service trusted? (instance allowlist)
- Is user banned from community?
- Does community allow remote posts?
- Rate limiting checks
- Returns scoped token valid for specific community + operation
Step 4: Post Creation (Modified)
- AppView uses service auth token to write to remote PDS
- Same
com.atproto.repo.createRecordendpoint as current implementation - Post record written to community's repository
Step 5: Indexing (Unchanged)
- PDS broadcasts to firehose
- All AppViews index via Jetstream consumers
Implementation Details#
Phase 1: Service Detection (Local vs Remote)#
File: internal/core/posts/service.go
func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) {
// ... existing validation ...
community, err := s.communityService.GetByDID(ctx, communityDID)
if err != nil {
return nil, err
}
// NEW: Route based on community location
if s.isLocalCommunity(community) {
return s.createLocalPost(ctx, community, req)
}
return s.createFederatedPost(ctx, community, req)
}
func (s *postService) isLocalCommunity(community *communities.Community) bool {
localPDSHost := extractHost(s.pdsURL)
communityPDSHost := extractHost(community.PDSURL)
return localPDSHost == communityPDSHost
}
Phase 2: Service Auth Client#
New File: internal/atproto/service_auth/client.go
type ServiceAuthClient interface {
// RequestServiceAuth obtains a scoped token for writing to remote community
RequestServiceAuth(ctx context.Context, opts ServiceAuthOptions) (*ServiceAuthToken, error)
}
type ServiceAuthOptions struct {
RemotePDSURL string // Remote PDS endpoint
CommunityDID string // Target community DID
UserDID string // Author DID (for validation)
Method string // "social.coves.community.post.create"
ExpiresIn int // Token lifetime (seconds)
}
type ServiceAuthToken struct {
Token string // JWT token for auth
ExpiresAt time.Time // When token expires
}
func (c *serviceAuthClient) RequestServiceAuth(ctx context.Context, opts ServiceAuthOptions) (*ServiceAuthToken, error) {
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.server.getServiceAuth", opts.RemotePDSURL)
payload := map[string]interface{}{
"aud": opts.CommunityDID,
"exp": time.Now().Add(time.Duration(opts.ExpiresIn) * time.Second).Unix(),
"lxm": opts.Method,
}
// Sign request with our instance DID credentials
signedReq, err := c.signRequest(payload)
if err != nil {
return nil, fmt.Errorf("failed to sign service auth request: %w", err)
}
resp, err := c.httpClient.Post(endpoint, signedReq)
if err != nil {
return nil, fmt.Errorf("service auth request failed: %w", err)
}
return parseServiceAuthResponse(resp)
}
Phase 3: Federated Post Creation#
File: internal/core/posts/service.go
func (s *postService) createFederatedPost(ctx context.Context, community *communities.Community, req CreatePostRequest) (*CreatePostResponse, error) {
// 1. Request service auth token from remote PDS
token, err := s.serviceAuthClient.RequestServiceAuth(ctx, service_auth.ServiceAuthOptions{
RemotePDSURL: community.PDSURL,
CommunityDID: community.DID,
UserDID: req.AuthorDID,
Method: "social.coves.community.post.create",
ExpiresIn: 300, // 5 minutes
})
if err != nil {
// Handle specific errors
if isUnauthorized(err) {
return nil, ErrNotAuthorizedRemote
}
if isBanned(err) {
return nil, ErrBannedRemote
}
return nil, fmt.Errorf("failed to obtain service auth: %w", err)
}
// 2. Build post record (same as local)
postRecord := PostRecord{
Type: "social.coves.community.post",
Community: community.DID,
Author: req.AuthorDID,
Title: req.Title,
Content: req.Content,
// ... other fields ...
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
// 3. Write to remote PDS using service auth token
uri, cid, err := s.createPostOnRemotePDS(ctx, community.PDSURL, community.DID, postRecord, token.Token)
if err != nil {
return nil, fmt.Errorf("failed to write to remote PDS: %w", err)
}
log.Printf("[FEDERATION] User %s posted to remote community %s: %s",
req.AuthorDID, community.DID, uri)
return &CreatePostResponse{
URI: uri,
CID: cid,
}, nil
}
func (s *postService) createPostOnRemotePDS(
ctx context.Context,
pdsURL string,
communityDID string,
record PostRecord,
serviceAuthToken string,
) (uri, cid string, err error) {
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", pdsURL)
payload := map[string]interface{}{
"repo": communityDID,
"collection": "social.coves.community.post",
"record": record,
}
jsonData, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
// Use service auth token instead of community credentials
// NOTE: Auth scheme depends on target PDS implementation:
// - Standard atproto service auth uses "Bearer" scheme
// - Our AppView uses "DPoP" scheme when DPoP-bound tokens are required
// For server-to-server with standard PDS, use Bearer; adjust based on target.
req.Header.Set("Authorization", "Bearer "+serviceAuthToken)
req.Header.Set("Content-Type", "application/json")
// ... execute request, parse response ...
return uri, cid, nil
}
Phase 4: PDS Service Auth Validation (PDS Extension)#
Note: This requires extending the PDS. Options:
- Contribute to official atproto PDS
- Run modified PDS fork
- Use PDS middleware/proxy
Conceptual Implementation:
// PDS validates service auth requests before issuing tokens
func (h *ServiceAuthHandler) HandleGetServiceAuth(w http.ResponseWriter, r *http.Request) {
var req ServiceAuthRequest
json.NewDecoder(r.Body).Decode(&req)
// 1. Verify requesting service is trusted
requestingDID := extractDIDFromJWT(r.Header.Get("Authorization"))
if !h.isTrustedInstance(requestingDID) {
writeError(w, http.StatusForbidden, "UntrustedInstance", "Instance not in allowlist")
return
}
// 2. Validate community exists on this PDS
community, err := h.getCommunityByDID(req.Aud)
if err != nil {
writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not hosted here")
return
}
// 3. Check user not banned (query from AppView or local moderation records)
if h.isUserBanned(req.UserDID, req.Aud) {
writeError(w, http.StatusForbidden, "Banned", "User banned from community")
return
}
// 4. Check community settings (allows remote posts?)
if !community.AllowFederatedPosts {
writeError(w, http.StatusForbidden, "FederationDisabled", "Community doesn't accept federated posts")
return
}
// 5. Rate limiting (per user, per community, per instance)
if h.exceedsRateLimit(req.UserDID, req.Aud, requestingDID) {
writeError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests")
return
}
// 6. Generate scoped token
token := h.issueServiceAuthToken(ServiceAuthTokenOptions{
Audience: req.Aud, // Community DID
Subject: requestingDID, // Requesting instance DID
Method: req.Lxm, // Authorized method
ExpiresAt: time.Unix(req.Exp, 0),
Scopes: []string{"write:posts"},
})
json.NewEncoder(w).Encode(map[string]string{
"token": token,
})
}
Database Schema Changes#
New Table: instance_federation#
Tracks trusted instances and federation settings:
CREATE TABLE instance_federation (
id SERIAL PRIMARY KEY,
instance_did TEXT NOT NULL UNIQUE,
instance_domain TEXT NOT NULL,
trust_level TEXT NOT NULL, -- 'trusted', 'limited', 'blocked'
allowed_methods TEXT[] NOT NULL DEFAULT '{}',
rate_limit_posts_per_hour INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
notes TEXT
);
CREATE INDEX idx_instance_federation_did ON instance_federation(instance_did);
CREATE INDEX idx_instance_federation_trust ON instance_federation(trust_level);
New Table: federation_rate_limits#
Track federated post rate limits:
CREATE TABLE federation_rate_limits (
id SERIAL PRIMARY KEY,
user_did TEXT NOT NULL,
community_did TEXT NOT NULL,
instance_did TEXT NOT NULL,
window_start TIMESTAMPTZ NOT NULL,
post_count INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_did, community_did, instance_did, window_start)
);
CREATE INDEX idx_federation_rate_limits_lookup
ON federation_rate_limits(user_did, community_did, instance_did, window_start);
Update Table: communities#
Add federation settings:
ALTER TABLE communities
ADD COLUMN allow_federated_posts BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN federation_mode TEXT NOT NULL DEFAULT 'open';
-- federation_mode: 'open' (any instance), 'allowlist' (trusted only), 'local' (no federation)
Security Considerations#
1. Instance Trust Model#
Allowlist Approach (Beta):
- Manual approval of federated instances
- Admin UI to manage instance trust levels
- Default: block all, explicit allow
Trust Levels:
trusted- Full federation, normal rate limitslimited- Federation allowed, strict rate limitsblocked- No federation
2. User Ban Synchronization#
Challenge: Remote instance needs to check local bans
Options:
- Service auth validation - PDS queries AppView for ban status
- Ban records in PDS - Moderation records stored in community repo
- Cached ban list - Remote instances cache ban lists (with TTL)
Beta Approach: Option 1 (service auth validation queries AppView)
3. Rate Limiting#
Multi-level rate limits:
- Per user per community: 10 posts/hour
- Per instance per community: 100 posts/hour
- Per user across all communities: 50 posts/hour
Implementation: In-memory + PostgreSQL for persistence
4. Content Validation#
Same validation as local posts:
- Lexicon validation
- Content length limits
- Embed validation
- Label validation
Additional federation checks:
- Verify author DID is valid
- Verify requesting instance signature
- Verify token scopes match operation
API Changes#
New Endpoint: social.coves.federation.getTrustedInstances#
Purpose: List instances this instance federates with
Lexicon:
{
"lexicon": 1,
"id": "social.coves.federation.getTrustedInstances",
"defs": {
"main": {
"type": "query",
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["instances"],
"properties": {
"instances": {
"type": "array",
"items": { "$ref": "#instanceView" }
}
}
}
}
},
"instanceView": {
"type": "object",
"required": ["did", "domain", "trustLevel"],
"properties": {
"did": { "type": "string" },
"domain": { "type": "string" },
"trustLevel": { "type": "string" },
"allowedMethods": { "type": "array", "items": { "type": "string" } }
}
}
}
}
Modified Endpoint: social.coves.community.post.create#
Changes:
- No API contract changes
- Internal routing: local vs federated
- New error codes:
FederationFailed- Remote instance unreachableRemoteNotAuthorized- Remote instance rejected authRemoteBanned- User banned on remote community
User Experience#
Happy Path: Cross-Instance Post#
- User on coves.social navigates to !gaming@covesinstance.com
- Clicks "Create Post"
- Fills out post form (title, content, etc.)
- Clicks "Submit"
- Behind the scenes:
- coves.social requests service auth from covesinstance.com
- covesinstance.com validates and issues token
- coves.social writes post using token
- Post appears in feed within seconds (via firehose)
- User sees: Post published successfully
- Post appears in:
- covesinstance.com feeds (native community)
- coves.social discover/all feeds (indexed via firehose)
- User's profile on coves.social
Error Cases#
User Banned:
- Error: "You are banned from !gaming@covesinstance.com"
- Suggestion: "Contact community moderators for more information"
Instance Blocked:
- Error: "This community does not accept posts from your instance"
- Suggestion: "Contact community administrators or create a local account"
Federation Unavailable:
- Error: "Unable to connect to covesinstance.com. Try again later."
- Fallback: Allow saving as draft (future feature)
Rate Limited:
- Error: "You're posting too quickly. Please wait before posting again."
- Show: Countdown until next post allowed
Testing Requirements#
Unit Tests#
-
Service Detection:
isLocalCommunity()correctly identifies local vs remote- Handles edge cases (different ports, subdomains)
-
Service Auth Client:
- Correctly formats service auth requests
- Handles token expiration
- Retries on transient failures
-
Federated Post Creation:
- Uses service auth token instead of community credentials
- Falls back gracefully on errors
- Logs federation events
Integration Tests#
-
Local Post (Regression):
- Posting to local community still works
- No performance degradation
-
Federated Post:
- User can post to remote community
- Service auth token requested correctly
- Post written to remote PDS
- Post indexed by both AppViews
-
Authorization Failures:
- Banned users rejected at service auth stage
- Untrusted instances rejected
- Expired tokens rejected
-
Rate Limiting:
- Per-user rate limits enforced
- Per-instance rate limits enforced
- Rate limit resets correctly
End-to-End Tests#
-
Cross-Instance User Journey:
- Set up two instances (instance-a, instance-b)
- Create community on instance-b
- User on instance-a posts to instance-b community
- Verify post appears on both instances
-
Moderation Enforcement:
- Ban user on remote instance
- Verify user can't post from any instance
- Unban user
- Verify user can post again
-
Instance Blocklist:
- Block instance-a on instance-b
- Verify users from instance-a can't post to instance-b communities
- Unblock instance-a
- Verify posting works again
Migration Path (Alpha → Beta)#
Phase 1: Backend Implementation (No User Impact)#
- Add service auth client
- Add local vs remote detection
- Deploy with feature flag
ENABLE_FEDERATION=false
Phase 2: Database Migration#
- Add federation tables
- Seed with initial trusted instances (manual)
- Add community federation flags (default: allow)
Phase 3: Soft Launch#
- Enable federation for single test instance
- Monitor service auth requests/errors
- Validate rate limiting works
Phase 4: Beta Rollout#
- Enable
ENABLE_FEDERATION=truefor all instances - Admin UI for managing trusted instances
- Community settings for federation preferences
Phase 5: Documentation & Onboarding#
- Instance operator guide: "How to federate with other instances"
- Community moderator guide: "Federation settings"
- User guide: "Posting across instances"
Metrics & Success Criteria#
Performance Metrics#
- Service auth request latency: p95 < 200ms
- Federated post creation time: p95 < 2 seconds (vs 500ms local)
- Service auth token cache hit rate: > 80%
Adoption Metrics#
- % of posts that are federated: Target 20% by end of Beta
- Number of federated instances: Target 5+ by end of Beta
- Cross-instance engagement (comments, votes): Monitor trend
Reliability Metrics#
- Service auth success rate: > 99%
- Federated post success rate: > 95%
- Service auth token validation errors: < 1%
Security Metrics#
- Unauthorized access attempts: Monitor & alert
- Rate limit triggers: Track per instance
- Ban evasion attempts: Zero tolerance
Rollback Plan#
If federation causes critical issues:
- Immediate: Set
ENABLE_FEDERATION=falsevia env var - Fallback: All posts route through local-only flow
- Investigation: Review logs for service auth failures
- Fix Forward: Deploy patch, re-enable gradually
No data loss: Posts are written to PDS, indexed via firehose regardless of federation method.
Open Questions#
-
Instance Discovery: How do users find communities on other instances?
- Beta: Manual (users share links)
- Future: Instance directory, community search across instances
-
Service Auth Token Caching: Should AppViews cache service auth tokens?
- Pros: Reduce latency, fewer PDS requests
- Cons: Stale permissions, ban enforcement delay
- Decision needed: Cache with short TTL (5 minutes)?
-
PDS Implementation: Who implements service auth validation?
- Option A: Contribute to official PDS (long timeline)
- Option B: Run forked PDS (maintenance burden)
- Option C: Proxy/middleware (added complexity)
- Decision needed: Start with Option B, migrate to Option A?
-
Federation Symmetry: If instance-a trusts instance-b, does instance-b auto-trust instance-a?
- Beta: No (asymmetric trust)
- Future: Mutual federation agreements?
-
Cross-Instance Moderation: Should bans propagate across instances?
- Beta: No (each instance decides)
- Future: Shared moderation lists?
Future Enhancements (Post-Beta)#
- Service Auth Token Caching: Reduce latency for frequent posters
- Batch Service Auth: Request tokens for multiple communities at once
- Instance Discovery API: Automatic instance detection/registration
- Federation Analytics: Dashboard showing cross-instance activity
- Moderation Sync: Optional shared ban lists across trusted instances
- Content Mirroring: Cache federated posts locally for performance
- User Migration: Transfer account between instances
Resources#
Documentation#
- atProto Service Auth Spec (hypothetical - check actual docs)
- Lemmy Federation Architecture
- Mastodon Federation Implementation
Code References#
internal/core/posts/service.go- Post creation serviceinternal/api/handlers/post/create.go- Post creation handlerinternal/atproto/jetstream/- Firehose consumers
Dependencies#
- atproto SDK (for service auth)
- PDS v0.4+ (service auth support)
- PostgreSQL 14+ (for federation tables)
Appendix A: Service Auth Request Example#
Request to Remote PDS:
POST https://covesinstance.com/xrpc/com.atproto.server.getServiceAuth
Authorization: DPoP {coves-social-instance-jwt}
DPoP: {coves-social-dpop-proof}
Content-Type: application/json
{
"aud": "did:plc:community123",
"exp": 1700000000,
"lxm": "social.coves.community.post.create"
}
Response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Using Token to Create Post:
POST https://covesinstance.com/xrpc/com.atproto.repo.createRecord
Authorization: DPoP {service-auth-token}
DPoP: {service-auth-dpop-proof}
Content-Type: application/json
{
"repo": "did:plc:community123",
"collection": "social.coves.community.post",
"record": {
"$type": "social.coves.community.post",
"community": "did:plc:community123",
"author": "did:plc:user456",
"title": "Hello from coves.social!",
"content": "This is a federated post",
"createdAt": "2024-11-16T12:00:00Z"
}
}
Appendix B: Error Handling Matrix#
| Error Condition | HTTP Status | Error Code | User Message | Retry Strategy |
|---|---|---|---|---|
| Instance not trusted | 403 | UntrustedInstance |
"This community doesn't accept posts from your instance" | No retry |
| User banned | 403 | Banned |
"You are banned from this community" | No retry |
| Rate limit exceeded | 429 | RateLimited |
"Too many posts. Try again in X minutes" | Exponential backoff |
| PDS unreachable | 503 | ServiceUnavailable |
"Community temporarily unavailable" | Retry 3x with backoff |
| Invalid token | 401 | InvalidToken |
"Session expired. Please try again" | Refresh token & retry |
| Community not found | 404 | CommunityNotFound |
"Community not found" | No retry |
| Service auth failed | 500 | FederationFailed |
"Unable to connect. Try again later" | Retry 2x |
End of PRD