A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

Changed files
+6342 -229
.beads
aggregators
docs
internal
scripts
static
tests
+1 -1
internal/atproto/utils/record_utils.go
···
// Format: at://did/collection/rkey -> collection
//
// Returns:
-
// - Collection name (e.g., "social.coves.feed.comment") if URI is well-formed
+
// - Collection name (e.g., "social.coves.community.comment") if URI is well-formed
// - Empty string if URI is malformed or doesn't contain a collection segment
//
// Note: Empty string indicates "unknown/unsupported collection" and should be
+1 -1
internal/core/comments/comment.go
···
// CommentRecord represents the atProto record structure indexed from Jetstream
// This is the data structure that gets stored in the user's repository
-
// Matches social.coves.feed.comment lexicon
+
// Matches social.coves.community.comment lexicon
type CommentRecord struct {
Embed map[string]interface{} `json:"embed,omitempty"`
Labels *SelfLabels `json:"labels,omitempty"`
+2 -2
internal/core/comments/view_models.go
···
)
// CommentView represents the full view of a comment with all metadata
-
// Matches social.coves.feed.getComments#commentView lexicon
+
// Matches social.coves.community.comment.getComments#commentView lexicon
// Used in thread views and get endpoints
type CommentView struct {
Embed interface{} `json:"embed,omitempty"`
···
}
// ThreadViewComment represents a comment with its nested replies
-
// Matches social.coves.feed.getComments#threadViewComment lexicon
+
// Matches social.coves.community.comment.getComments#threadViewComment lexicon
// Supports recursive threading for comment trees
type ThreadViewComment struct {
Comment *CommentView `json:"comment"`
+1 -1
internal/validation/lexicon.go
···
// ValidateComment validates a comment record
func (v *LexiconValidator) ValidateComment(comment map[string]interface{}) error {
-
return v.ValidateRecord(comment, "social.coves.feed.comment")
+
return v.ValidateRecord(comment, "social.coves.community.comment")
}
// ValidateVote validates a vote record
+1 -1
scripts/generate_deep_thread.go
···
func createComment(db *sql.DB, user *User, content, parentURI, parentCID string, createdAt time.Time) (*Comment, error) {
rkey := generateTID()
-
uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", user.DID, rkey)
+
uri := fmt.Sprintf("at://%s/social.coves.community.comment/%s", user.DID, rkey)
cid := fmt.Sprintf("bafy%s", rkey)
comment := &Comment{
+1 -1
scripts/generate_nba_comments.go
···
func createComment(db *sql.DB, user *User, content, parentURI, parentCID string, createdAt time.Time) (*Comment, error) {
rkey := generateTID()
-
uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", user.DID, rkey)
+
uri := fmt.Sprintf("at://%s/social.coves.community.comment/%s", user.DID, rkey)
cid := fmt.Sprintf("bafy%s", rkey)
comment := &Comment{
+1 -1
scripts/generate_test_comments.go
···
func createComment(db *sql.DB, user *User, content, parentURI, parentCID string, createdAt time.Time) (*Comment, error) {
rkey := generateTID()
-
uri := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", user.DID, rkey)
+
uri := fmt.Sprintf("at://%s/social.coves.community.comment/%s", user.DID, rkey)
cid := fmt.Sprintf("bafy%s", rkey)
comment := &Comment{
+18 -15
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
···
```json
{
"lexicon": 1,
-
"id": "social.coves.feed.comment",
+
"id": "social.coves.community.comment",
"defs": {
"main": {
"type": "record",
···
```sql
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
-
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.feed.comment/rkey)
+
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://commenter_did/social.coves.community.comment/rkey)
cid TEXT NOT NULL, -- Content ID
rkey TEXT NOT NULL, -- Record key (TID)
commenter_did TEXT NOT NULL, -- User who commented (from AT-URI repo field)
···
return nil
}
-
if event.Commit.Collection == "social.coves.feed.comment" {
+
if event.Commit.Collection == "social.coves.community.comment" {
switch event.Commit.Operation {
case "create":
return c.createComment(ctx, event.Did, commit)
···
- Auto-reconnect on errors (5-second retry)
- Ping/pong keepalive (30-second ping, 60-second read deadline)
- Graceful shutdown via context cancellation
-
- Subscribes to: `wantedCollections=social.coves.feed.comment`
+
- Subscribes to: `wantedCollections=social.coves.community.comment`
---
···
// Start Jetstream consumer for comments
commentJetstreamURL := os.Getenv("COMMENT_JETSTREAM_URL")
if commentJetstreamURL == "" {
-
commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"
+
commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.community.comment"
}
commentEventConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
···
}()
log.Printf("Started Jetstream comment consumer: %s", commentJetstreamURL)
-
log.Println(" - Indexing: social.coves.feed.comment CREATE/UPDATE/DELETE operations")
+
log.Println(" - Indexing: social.coves.community.comment CREATE/UPDATE/DELETE operations")
log.Println(" - Updating: Post comment counts and comment reply counts atomically")
```
···
| Aspect | Votes | Comments |
|--------|-------|----------|
| **Location** | User repositories | User repositories |
-
| **Lexicon** | `social.coves.feed.vote` | `social.coves.feed.comment` |
+
| **Lexicon** | `social.coves.feed.vote` | `social.coves.community.comment` |
| **Operations** | CREATE, DELETE | CREATE, UPDATE, DELETE |
| **Mutability** | Immutable | Editable |
| **Foreign Keys** | None (out-of-order indexing) | None (out-of-order indexing) |
···
---
-
### ๐Ÿ“‹ Phase 4: Namespace Migration (Separate Task)
+
### โœ… Phase 4: Namespace Migration (COMPLETED)
+
+
**Completed:** 2025-11-16
**Scope:**
-
- Migrate existing `social.coves.feed.comment` records to `social.coves.community.comment`
-
- Update all AT-URIs in database
-
- Update Jetstream consumer collection filter
-
- Migration script with rollback capability
-
- Zero-downtime deployment strategy
+
- โœ… Migrated `social.coves.community.comment` namespace to `social.coves.community.comment`
+
- โœ… Updated lexicon definitions (record and query schemas)
+
- โœ… Updated Jetstream consumer collection filter
+
- โœ… Updated all code references (consumer, service, validation layers)
+
- โœ… Updated integration tests and test data generation scripts
+
- โœ… Created database migration (018_migrate_comment_namespace.sql)
-
**Note:** Currently out of scope - will be tackled separately when needed.
+
**Note:** Since we're pre-production, no historical data migration was needed. Migration script updates URIs in comments table (uri, root_uri, parent_uri columns).
---
···
### Environment Variables
```bash
# Jetstream URL (optional, defaults to localhost:6008)
-
export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"
+
export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.community.comment"
# Database URL
export TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+3 -2
internal/core/unfurl/providers.go
···
// normalizeURL converts protocol-relative URLs to HTTPS
// Examples:
-
// "//example.com/image.jpg" -> "https://example.com/image.jpg"
-
// "https://example.com/image.jpg" -> "https://example.com/image.jpg" (unchanged)
+
//
+
// "//example.com/image.jpg" -> "https://example.com/image.jpg"
+
// "https://example.com/image.jpg" -> "https://example.com/image.jpg" (unchanged)
func normalizeURL(urlStr string) string {
if strings.HasPrefix(urlStr, "//") {
return "https:" + urlStr
-1
internal/core/unfurl/providers_test.go
···
})
}
}
-
+785
docs/federation-prd.md
···
+
# 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
+
1. **Enable cross-instance posting** - Users can post to any community on any federated instance
+
2. **Preserve community ownership** - Posts live in community repos, not user repos
+
3. **atProto-native implementation** - Use `com.atproto.server.getServiceAuth` pattern
+
4. **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:
+
```json
+
{
+
"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.createRecord` endpoint 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`
+
+
```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`
+
+
```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`
+
+
```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
+
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:
+
1. Contribute to official atproto PDS
+
2. Run modified PDS fork
+
3. Use PDS middleware/proxy
+
+
**Conceptual Implementation:**
+
+
```go
+
// 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:
+
+
```sql
+
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:
+
+
```sql
+
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:
+
+
```sql
+
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 limits
+
- `limited` - Federation allowed, strict rate limits
+
- `blocked` - No federation
+
+
### 2. User Ban Synchronization
+
+
**Challenge:** Remote instance needs to check local bans
+
+
**Options:**
+
1. **Service auth validation** - PDS queries AppView for ban status
+
2. **Ban records in PDS** - Moderation records stored in community repo
+
3. **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:**
+
```json
+
{
+
"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 unreachable
+
- `RemoteNotAuthorized` - Remote instance rejected auth
+
- `RemoteBanned` - User banned on remote community
+
+
---
+
+
## User Experience
+
+
### Happy Path: Cross-Instance Post
+
+
1. User on coves.social navigates to !gaming@covesinstance.com
+
2. Clicks "Create Post"
+
3. Fills out post form (title, content, etc.)
+
4. Clicks "Submit"
+
5. **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)
+
6. **User sees:** Post published successfully
+
7. 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
+
+
1. **Service Detection:**
+
- `isLocalCommunity()` correctly identifies local vs remote
+
- Handles edge cases (different ports, subdomains)
+
+
2. **Service Auth Client:**
+
- Correctly formats service auth requests
+
- Handles token expiration
+
- Retries on transient failures
+
+
3. **Federated Post Creation:**
+
- Uses service auth token instead of community credentials
+
- Falls back gracefully on errors
+
- Logs federation events
+
+
### Integration Tests
+
+
1. **Local Post (Regression):**
+
- Posting to local community still works
+
- No performance degradation
+
+
2. **Federated Post:**
+
- User can post to remote community
+
- Service auth token requested correctly
+
- Post written to remote PDS
+
- Post indexed by both AppViews
+
+
3. **Authorization Failures:**
+
- Banned users rejected at service auth stage
+
- Untrusted instances rejected
+
- Expired tokens rejected
+
+
4. **Rate Limiting:**
+
- Per-user rate limits enforced
+
- Per-instance rate limits enforced
+
- Rate limit resets correctly
+
+
### End-to-End Tests
+
+
1. **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
+
+
2. **Moderation Enforcement:**
+
- Ban user on remote instance
+
- Verify user can't post from any instance
+
- Unban user
+
- Verify user can post again
+
+
3. **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)
+
1. Add service auth client
+
2. Add local vs remote detection
+
3. Deploy with feature flag `ENABLE_FEDERATION=false`
+
+
### Phase 2: Database Migration
+
1. Add federation tables
+
2. Seed with initial trusted instances (manual)
+
3. Add community federation flags (default: allow)
+
+
### Phase 3: Soft Launch
+
1. Enable federation for single test instance
+
2. Monitor service auth requests/errors
+
3. Validate rate limiting works
+
+
### Phase 4: Beta Rollout
+
1. Enable `ENABLE_FEDERATION=true` for all instances
+
2. Admin UI for managing trusted instances
+
3. Community settings for federation preferences
+
+
### Phase 5: Documentation & Onboarding
+
1. Instance operator guide: "How to federate with other instances"
+
2. Community moderator guide: "Federation settings"
+
3. 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:
+
+
1. **Immediate:** Set `ENABLE_FEDERATION=false` via env var
+
2. **Fallback:** All posts route through local-only flow
+
3. **Investigation:** Review logs for service auth failures
+
4. **Fix Forward:** Deploy patch, re-enable gradually
+
+
**No data loss:** Posts are written to PDS, indexed via firehose regardless of federation method.
+
+
---
+
+
## Open Questions
+
+
1. **Instance Discovery:** How do users find communities on other instances?
+
- Beta: Manual (users share links)
+
- Future: Instance directory, community search across instances
+
+
2. **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)?
+
+
3. **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?
+
+
4. **Federation Symmetry:** If instance-a trusts instance-b, does instance-b auto-trust instance-a?
+
- Beta: No (asymmetric trust)
+
- Future: Mutual federation agreements?
+
+
5. **Cross-Instance Moderation:** Should bans propagate across instances?
+
- Beta: No (each instance decides)
+
- Future: Shared moderation lists?
+
+
---
+
+
## Future Enhancements (Post-Beta)
+
+
1. **Service Auth Token Caching:** Reduce latency for frequent posters
+
2. **Batch Service Auth:** Request tokens for multiple communities at once
+
3. **Instance Discovery API:** Automatic instance detection/registration
+
4. **Federation Analytics:** Dashboard showing cross-instance activity
+
5. **Moderation Sync:** Optional shared ban lists across trusted instances
+
6. **Content Mirroring:** Cache federated posts locally for performance
+
7. **User Migration:** Transfer account between instances
+
+
---
+
+
## Resources
+
+
### Documentation
+
- [atProto Service Auth Spec](https://atproto.com/specs/service-auth) (hypothetical - check actual docs)
+
- Lemmy Federation Architecture
+
- Mastodon Federation Implementation
+
+
### Code References
+
- `internal/core/posts/service.go` - Post creation service
+
- `internal/api/handlers/post/create.go` - Post creation handler
+
- `internal/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:**
+
```http
+
POST https://covesinstance.com/xrpc/com.atproto.server.getServiceAuth
+
Authorization: Bearer {coves-social-instance-jwt}
+
Content-Type: application/json
+
+
{
+
"aud": "did:plc:community123",
+
"exp": 1700000000,
+
"lxm": "social.coves.community.post.create"
+
}
+
```
+
+
**Response:**
+
```http
+
HTTP/1.1 200 OK
+
Content-Type: application/json
+
+
{
+
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
+
}
+
```
+
+
**Using Token to Create Post:**
+
```http
+
POST https://covesinstance.com/xrpc/com.atproto.repo.createRecord
+
Authorization: Bearer {service-auth-token}
+
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**
+130 -28
docs/PRD_ALPHA_GO_LIVE.md
···
## ๐ŸŽฏ Major Progress Update
**โœ… ALL E2E TESTS COMPLETE!** (Completed 2025-11-16)
+
**โœ… BIDIRECTIONAL DID VERIFICATION COMPLETE!** (Completed 2025-11-16)
All 6 critical E2E test suites have been implemented and are passing:
- โœ… Full User Journey (signup โ†’ community โ†’ post โ†’ comment โ†’ vote)
···
**Time Saved**: ~7-12 hours through parallel agent implementation
**Test Quality**: Enhanced with comprehensive database record verification to catch race conditions
+
### Production Deployment Requirements
+
+
**Architecture**:
+
- **AppView Domain**: coves.social (instance identity, API, frontend)
+
- **PDS Domain**: coves.me (separate domain required - cannot be same as AppView)
+
- **Community Handles**: Use @coves.social (AppView domain)
+
- **Jetstream**: Connects to Bluesky's production firehose (wss://jetstream2.us-east.bsky.network)
+
+
**Required: .well-known/did.json at coves.social**:
+
```json
+
{
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "z..."
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://coves.me"
+
}
+
]
+
}
+
```
+
+
**Environment Variables**:
+
- AppView:
+
- `INSTANCE_DID=did:web:coves.social`
+
- `INSTANCE_DOMAIN=coves.social`
+
- `PDS_URL=https://coves.me` (separate domain)
+
- `SKIP_DID_WEB_VERIFICATION=false` (production)
+
- `JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe`
+
+
**Verification**:
+
- `curl https://coves.social/.well-known/did.json` (should return DID document)
+
- `curl https://coves.me/xrpc/_health` (PDS health check)
+
## Overview
This document tracks the remaining work required to launch Coves alpha with real users. Focus is on critical functionality, security, and operational readiness.
···
### 1. Authentication & Security
+
#### Production PDS Deployment
+
**CRITICAL**: PDS must be on separate domain from AppView (coves.me, not coves.social)
+
+
- [ ] Deploy PDS to coves.me domain
+
- [ ] Set up DNS: A record for coves.me โ†’ server IP
+
- [ ] Configure SSL certificate for coves.me
+
- [ ] Deploy PDS container/service on port 2583
+
- [ ] Configure nginx/Caddy reverse proxy for coves.me โ†’ localhost:2583
+
- [ ] Set PDS_HOSTNAME=coves.me in PDS environment
+
- [ ] Mount persistent volume for PDS data (/pds/data)
+
- [ ] Verify PDS connectivity
+
- [ ] Test: `curl https://coves.me/xrpc/_health`
+
- [ ] Create test community account on PDS
+
- [ ] Verify JWKS endpoint: `curl https://coves.me/.well-known/jwks.json`
+
- [ ] Test community account token provisioning
+
- [ ] Configure AppView to use production PDS
+
- [ ] Set `PDS_URL=https://coves.me` in AppView .env
+
- [ ] Test community creation flow (provisions account on coves.me)
+
- [ ] Verify account provisioning works end-to-end
+
+
**Important**: Jetstream connects to Bluesky's production firehose, which automatically includes events from all production PDS instances (including coves.me once it's live)
+
+
**Estimated Effort**: 4-6 hours
+
**Risk**: Medium (infrastructure setup, DNS propagation)
+
#### JWT Signature Verification (Production Mode)
-
- [ ] Test with production PDS at `pds.bretton.dev`
-
- [ ] Create test account on production PDS
-
- [ ] Verify JWKS endpoint is accessible
+
- [ ] Test with production PDS at coves.me
+
- [ ] Verify JWKS endpoint is accessible: `https://coves.me/.well-known/jwks.json`
- [ ] Run `TestJWTSignatureVerification` against production PDS
- [ ] Confirm signature verification succeeds
-
- [ ] Test token refresh flow
+
- [ ] Test token refresh flow for community accounts
- [ ] Set `AUTH_SKIP_VERIFY=false` in production environment
- [ ] Verify all auth middleware tests pass with verification enabled
-
- [ ] Document production PDS requirements for communities
-
-
**Estimated Effort**: 2-3 hours
-
**Risk**: Medium (code implemented, needs validation)
-
-
#### did:web Verification
-
- [ ] Complete did:web domain verification implementation
-
- [ ] Test with real did:web identities
-
- [ ] Add security logging for verification failures
-
- [ ] Set `SKIP_DID_WEB_VERIFICATION=false` for production
**Estimated Effort**: 2-3 hours
-
**Risk**: Medium
+
**Risk**: Low (depends on PDS deployment)
+
+
#### did:web Verification โœ… COMPLETE
+
- [x] Complete did:web domain verification implementation (2025-11-16)
+
- [x] Implement Bluesky-compatible bidirectional verification
+
- [x] Add alsoKnownAs field verification in DID documents
+
- [x] Add security logging for verification failures
+
- [x] Update cache TTL to 24h (matches Bluesky recommendations)
+
- [x] Comprehensive test coverage with mock HTTP servers
+
- [ ] Set `SKIP_DID_WEB_VERIFICATION=false` for production (dev default: true)
+
- [ ] Deploy `.well-known/did.json` to production domain
+
+
**Implementation Details**:
+
- **Location**: [internal/atproto/jetstream/community_consumer.go](../internal/atproto/jetstream/community_consumer.go)
+
- **Verification Flow**: Domain matching + DID document fetch + alsoKnownAs validation
+
- **Security Model**: Matches Bluesky (DNS/HTTPS authority + bidirectional binding)
+
- **Performance**: Bounded LRU cache (1000 entries), rate limiting (10 req/s), 24h TTL
+
- **Impact**: AppView indexing and federation trust (not community creation API)
+
- **Tests**: `tests/integration/community_hostedby_security_test.go`
+
+
**Actual Effort**: 3 hours (implementation + testing)
+
**Risk**: โœ… Low (complete and tested)
### 2. DPoP Token Architecture Fix
···
- [ ] Common issues and fixes
- [ ] Emergency procedures (PDS down, database down, etc.)
- [ ] Create production environment checklist
-
- [ ] All environment variables set
-
- [ ] `AUTH_SKIP_VERIFY=false`
-
- [ ] `SKIP_DID_WEB_VERIFICATION=false`
-
- [ ] Database migrations applied
-
- [ ] PDS connectivity verified
-
- [ ] JWKS caching working
-
- [ ] Jetstream consumers running
+
- [ ] **Domain Setup**
+
- [ ] AppView domain (coves.social) DNS configured
+
- [ ] PDS domain (coves.me) DNS configured - MUST be separate domain
+
- [ ] SSL certificates for both domains
+
- [ ] Nginx/Caddy reverse proxy configured for both domains
+
- [ ] **AppView Environment Variables**
+
- [ ] `INSTANCE_DID=did:web:coves.social`
+
- [ ] `INSTANCE_DOMAIN=coves.social`
+
- [ ] `PDS_URL=https://coves.me` (separate domain)
+
- [ ] `AUTH_SKIP_VERIFY=false`
+
- [ ] `SKIP_DID_WEB_VERIFICATION=false`
+
- [ ] `JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe`
+
- [ ] **PDS Environment Variables**
+
- [ ] `PDS_HOSTNAME=coves.me`
+
- [ ] `PDS_PORT=2583`
+
- [ ] Persistent storage mounted
+
- [ ] **Deployment Verification**
+
- [ ] Deploy `.well-known/did.json` to coves.social with `serviceEndpoint: https://coves.me`
+
- [ ] Verify: `curl https://coves.social/.well-known/did.json`
+
- [ ] Verify: `curl https://coves.me/xrpc/_health`
+
- [ ] Database migrations applied
+
- [ ] PDS connectivity verified from AppView
+
- [ ] JWKS caching working
+
- [ ] Jetstream consumer connected to Bluesky production firehose
+
- [ ] Test community creation end-to-end
- [ ] Monitoring and alerting active
**Estimated Effort**: 6-8 hours
···
## Timeline Estimate
### Week 1: Critical Blockers (P0)
-
- **Days 1-2**: Authentication (JWT + did:web verification)
+
- ~~**Days 1-2**: Authentication (JWT + did:web verification)~~ โœ… **did:web COMPLETED**
+
- **Day 1**: Production PDS deployment (coves.me domain setup)
+
- **Day 2**: JWT signature verification with production PDS
- **Day 3**: DPoP token architecture fix
- ~~**Day 4**: Handle resolution + comment count reconciliation~~ โœ… **COMPLETED**
- **Day 4-5**: Testing and bug fixes
-
**Total**: 15-20 hours (reduced from 20-25 due to completed items)
+
**Total**: 16-23 hours (added 4-6 hours for PDS deployment, reduced from original due to did:web completion)
### Week 2: Production Infrastructure (P1)
- **Days 6-7**: Monitoring + structured logging
···
**Total**: ~~20-25 hours~~ โ†’ **13 hours actual** (E2E tests) + 7-12 hours remaining (load testing, polish)
-
**Grand Total: ~~65-80 hours~~ โ†’ 50-65 hours remaining (approximately 1.5-2 weeks full-time)**
-
*(Originally 70-85 hours. Reduced by completed items: handle resolution, comment count reconciliation, and ALL E2E tests)*
+
**Grand Total: ~~65-80 hours~~ โ†’ 51-68 hours remaining (approximately 1.5-2 weeks full-time)**
+
*(Originally 70-85 hours. Adjusted for: +4-6 hours PDS deployment, -3 hours did:web completion, -13 hours E2E tests completion, -4 hours handle resolution and comment reconciliation)*
**โœ… Progress Update**: E2E testing section COMPLETE ahead of schedule - saved ~7-12 hours through parallel agent implementation
···
- [ ] All P0 blockers resolved
- โœ… Handle resolution (COMPLETE)
- โœ… Comment count reconciliation (COMPLETE)
+
- โœ… did:web verification (COMPLETE - needs production deployment)
+
- [ ] Production PDS deployed to coves.me (separate domain)
- [ ] JWT signature verification working with production PDS
- [ ] DPoP architecture fix implemented
-
- [ ] did:web verification complete
- [ ] Subscriptions/blocking work via client-write pattern
- [x] **All integration tests passing** โœ…
- [x] **E2E user journey test passing** โœ…
···
11. [ ] Go/no-go decision
12. [ ] Launch! ๐Ÿš€
-
**๐ŸŽ‰ Major Milestone**: All E2E tests complete! Test coverage now includes full user journey, blob uploads, concurrent operations, rate limiting, and error recovery.
+
**๐ŸŽ‰ Major Milestones**:
+
- All E2E tests complete! Test coverage now includes full user journey, blob uploads, concurrent operations, rate limiting, and error recovery.
+
- Bidirectional DID verification complete! Bluesky-compatible security model with alsoKnownAs validation, 24h cache TTL, and comprehensive test coverage.
+18
internal/api/routes/aggregator.go
···
import (
"Coves/internal/api/handlers/aggregator"
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/identity"
"Coves/internal/core/aggregators"
+
"Coves/internal/core/users"
+
"net/http"
+
"time"
"github.com/go-chi/chi/v5"
)
···
func RegisterAggregatorRoutes(
r chi.Router,
aggregatorService aggregators.Service,
+
userService users.UserService,
+
identityResolver identity.Resolver,
) {
// Create query handlers
getServicesHandler := aggregator.NewGetServicesHandler(aggregatorService)
getAuthorizationsHandler := aggregator.NewGetAuthorizationsHandler(aggregatorService)
listForCommunityHandler := aggregator.NewListForCommunityHandler(aggregatorService)
+
// Create registration handler
+
registerHandler := aggregator.NewRegisterHandler(userService, identityResolver)
+
// Query endpoints (public - no auth required)
// GET /xrpc/social.coves.aggregator.getServices?dids=did:plc:abc,did:plc:def
// Following app.bsky.feed.getFeedGenerators pattern
···
// Lists aggregators authorized by a community
r.Get("/xrpc/social.coves.aggregator.listForCommunity", listForCommunityHandler.HandleListForCommunity)
+
// Registration endpoint (public - no auth required)
+
// Aggregators register themselves after creating their own PDS accounts
+
// POST /xrpc/social.coves.aggregator.register
+
// Rate limited to 10 requests per 10 minutes per IP to prevent abuse
+
registrationRateLimiter := middleware.NewRateLimiter(10, 10*time.Minute)
+
r.Post("/xrpc/social.coves.aggregator.register",
+
registrationRateLimiter.Middleware(http.HandlerFunc(registerHandler.HandleRegister)).ServeHTTP)
+
// Write endpoints (Phase 2 - require authentication and moderator permissions)
// TODO: Implement after Jetstream consumer is ready
// POST /xrpc/social.coves.aggregator.enable (requires auth + moderator)
+73
internal/atproto/lexicon/social/coves/aggregator/register.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.aggregator.register",
+
"defs": {
+
"main": {
+
"type": "procedure",
+
"description": "Register an existing aggregator DID with this Coves instance. Aggregators must first create their own DID via PLC directory, then call this endpoint to register. Domain ownership is verified via .well-known/atproto-did file.",
+
"input": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "domain"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the aggregator (did:plc or did:web format)"
+
},
+
"domain": {
+
"type": "string",
+
"format": "uri",
+
"description": "Domain where the aggregator is hosted (e.g., 'rss-bot.example.com'). Must serve .well-known/atproto-did file containing the DID."
+
}
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"required": ["did", "handle"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the registered aggregator"
+
},
+
"handle": {
+
"type": "string",
+
"description": "Handle extracted from DID document"
+
},
+
"message": {
+
"type": "string",
+
"description": "Success message with next steps"
+
}
+
}
+
}
+
},
+
"errors": [
+
{
+
"name": "InvalidDID",
+
"description": "DID format is invalid or not did:plc or did:web format"
+
},
+
{
+
"name": "DomainVerificationFailed",
+
"description": "Could not verify domain ownership via .well-known/atproto-did or DID mismatch"
+
},
+
{
+
"name": "AlreadyRegistered",
+
"description": "This aggregator DID is already registered with this instance"
+
},
+
{
+
"name": "DIDResolutionFailed",
+
"description": "Could not resolve DID document to extract handle and PDS URL"
+
},
+
{
+
"name": "RegistrationFailed",
+
"description": "Internal server error occurred during registration"
+
}
+
]
+
}
+
}
+
}
+591
docs/aggregators/SETUP_GUIDE.md
···
+
# Aggregator Setup Guide
+
+
This guide explains how to set up and register an aggregator with Coves instances.
+
+
## Table of Contents
+
+
- [Overview](#overview)
+
- [Architecture](#architecture)
+
- [Prerequisites](#prerequisites)
+
- [Quick Start](#quick-start)
+
- [Detailed Setup Steps](#detailed-setup-steps)
+
- [Authorization Process](#authorization-process)
+
- [Posting to Communities](#posting-to-communities)
+
- [Rate Limits](#rate-limits)
+
- [Security Best Practices](#security-best-practices)
+
- [Troubleshooting](#troubleshooting)
+
- [API Reference](#api-reference)
+
+
## Overview
+
+
**Aggregators** are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers - self-managed external services that integrate with the platform.
+
+
**Key characteristics**:
+
- Self-owned: You create and manage your own PDS account
+
- Domain-verified: Prove ownership via `.well-known/atproto-did`
+
- Community-authorized: Moderators grant posting permission per-community
+
- Rate-limited: 10 posts per hour per community
+
+
**Example use cases**:
+
- RSS feed aggregators (tech news, blog posts)
+
- Social media cross-posters (Twitter โ†’ Coves)
+
- Event notifications (GitHub releases, weather alerts)
+
- Content curation bots (daily links, summaries)
+
+
## Architecture
+
+
### Data Flow
+
+
```
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ 1. One-Time Setup โ”‚
+
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+
โ”‚ Aggregator creates PDS account โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Proves domain ownership (.well-known) โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Registers with Coves (enters users table) โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Writes service declaration โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Jetstream indexes into aggregators table โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ 2. Per-Community Authorization โ”‚
+
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+
โ”‚ Moderator writes authorization record โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Jetstream indexes into aggregator_authorizations โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ 3. Posting (Ongoing) โ”‚
+
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
+
โ”‚ Aggregator calls post creation endpoint โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Handler validates: โ”‚
+
โ”‚ - Author in users table โœ“ โ”‚
+
โ”‚ - Author in aggregators table โœ“ โ”‚
+
โ”‚ - Authorization exists โœ“ โ”‚
+
โ”‚ - Rate limit not exceeded โœ“ โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Post written to community's PDS โ”‚
+
โ”‚ โ†“ โ”‚
+
โ”‚ Jetstream indexes post โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
```
+
+
### Database Tables
+
+
**users** - All actors (users, communities, aggregators)
+
```sql
+
CREATE TABLE users (
+
did TEXT PRIMARY KEY,
+
handle TEXT NOT NULL,
+
pds_url TEXT,
+
indexed_at TIMESTAMPTZ
+
);
+
```
+
+
**aggregators** - Aggregator-specific metadata
+
```sql
+
CREATE TABLE aggregators (
+
did TEXT PRIMARY KEY,
+
display_name TEXT NOT NULL,
+
description TEXT,
+
avatar_url TEXT,
+
config_schema JSONB,
+
source_url TEXT,
+
maintainer_did TEXT,
+
record_uri TEXT NOT NULL UNIQUE,
+
record_cid TEXT NOT NULL,
+
created_at TIMESTAMPTZ,
+
indexed_at TIMESTAMPTZ
+
);
+
```
+
+
**aggregator_authorizations** - Community authorizations
+
```sql
+
CREATE TABLE aggregator_authorizations (
+
id BIGSERIAL PRIMARY KEY,
+
aggregator_did TEXT NOT NULL,
+
community_did TEXT NOT NULL,
+
enabled BOOLEAN NOT NULL DEFAULT true,
+
config JSONB,
+
created_by TEXT,
+
record_uri TEXT NOT NULL UNIQUE,
+
record_cid TEXT NOT NULL,
+
UNIQUE(aggregator_did, community_did)
+
);
+
```
+
+
## Prerequisites
+
+
1. **Domain ownership**: You must own a domain where you can host static files over HTTPS
+
2. **Web server**: Ability to serve the `.well-known/atproto-did` file
+
3. **Development tools**: `curl`, `jq`, basic shell scripting knowledge
+
4. **Email address**: For creating the PDS account
+
+
**Optional**:
+
- Custom avatar image (PNG/JPEG/WebP, max 1MB)
+
- GitHub repository for source code transparency
+
+
## Quick Start
+
+
We provide automated setup scripts:
+
+
```bash
+
cd scripts/aggregator-setup
+
+
# Make scripts executable
+
chmod +x *.sh
+
+
# Run setup scripts in order
+
./1-create-pds-account.sh
+
./2-setup-wellknown.sh
+
# (Upload .well-known to your web server)
+
./3-register-with-coves.sh
+
./4-create-service-declaration.sh
+
```
+
+
See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed script documentation.
+
+
## Detailed Setup Steps
+
+
### Step 1: Create PDS Account
+
+
Your aggregator needs its own atProto identity (DID). The easiest way is to create an account on an existing PDS.
+
+
**Using an existing PDS (recommended)**:
+
+
```bash
+
curl -X POST https://bsky.social/xrpc/com.atproto.server.createAccount \
+
-H "Content-Type: application/json" \
+
-d '{
+
"handle": "mynewsbot.bsky.social",
+
"email": "bot@example.com",
+
"password": "secure-password-here"
+
}'
+
```
+
+
**Response**:
+
```json
+
{
+
"accessJwt": "eyJ...",
+
"refreshJwt": "eyJ...",
+
"handle": "mynewsbot.bsky.social",
+
"did": "did:plc:abc123...",
+
"didDoc": {...}
+
}
+
```
+
+
**Save these credentials securely!** You'll need the DID and access token for all subsequent operations.
+
+
**Alternative**: Run your own PDS or use `did:web` (advanced).
+
+
### Step 2: Prove Domain Ownership
+
+
To register with Coves, you must prove you own a domain by serving your DID at `https://yourdomain.com/.well-known/atproto-did`.
+
+
**Create the file**:
+
+
```bash
+
mkdir -p .well-known
+
echo "did:plc:abc123..." > .well-known/atproto-did
+
```
+
+
**Upload to your web server** so it's accessible at:
+
```
+
https://rss-bot.example.com/.well-known/atproto-did
+
```
+
+
**Verify it works**:
+
```bash
+
curl https://rss-bot.example.com/.well-known/atproto-did
+
# Should return: did:plc:abc123...
+
```
+
+
**Nginx configuration example**:
+
```nginx
+
location /.well-known/atproto-did {
+
alias /var/www/.well-known/atproto-did;
+
default_type text/plain;
+
add_header Access-Control-Allow-Origin *;
+
}
+
```
+
+
### Step 3: Register with Coves
+
+
Call the registration endpoint to register your aggregator DID with the Coves instance.
+
+
**Endpoint**: `POST /xrpc/social.coves.aggregator.register`
+
+
**Request**:
+
```bash
+
curl -X POST https://api.coves.social/xrpc/social.coves.aggregator.register \
+
-H "Content-Type: application/json" \
+
-d '{
+
"did": "did:plc:abc123...",
+
"domain": "rss-bot.example.com"
+
}'
+
```
+
+
**Response** (Success):
+
```json
+
{
+
"did": "did:plc:abc123...",
+
"handle": "mynewsbot.bsky.social",
+
"message": "Aggregator registered successfully. Next step: create a service declaration record at at://did:plc:abc123.../social.coves.aggregator.service/self"
+
}
+
```
+
+
**What happens**:
+
1. Coves fetches `https://rss-bot.example.com/.well-known/atproto-did`
+
2. Verifies it contains your DID
+
3. Resolves your DID to get handle and PDS URL
+
4. Inserts you into the `users` table
+
+
**You're now registered!** But you need to create a service declaration next.
+
+
### Step 4: Create Service Declaration
+
+
Write a `social.coves.aggregator.service` record to your repository. This contains metadata about your aggregator and gets indexed by Coves' Jetstream consumer.
+
+
**Endpoint**: `POST https://your-pds.com/xrpc/com.atproto.repo.createRecord`
+
+
**Request**:
+
```bash
+
curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \
+
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
+
-H "Content-Type: application/json" \
+
-d '{
+
"repo": "did:plc:abc123...",
+
"collection": "social.coves.aggregator.service",
+
"rkey": "self",
+
"record": {
+
"$type": "social.coves.aggregator.service",
+
"did": "did:plc:abc123...",
+
"displayName": "RSS News Aggregator",
+
"description": "Aggregates tech news from various RSS feeds",
+
"sourceUrl": "https://github.com/yourname/rss-aggregator",
+
"maintainer": "did:plc:your-personal-did",
+
"createdAt": "2024-01-15T12:00:00Z"
+
}
+
}'
+
```
+
+
**Response**:
+
```json
+
{
+
"uri": "at://did:plc:abc123.../social.coves.aggregator.service/self",
+
"cid": "bafyrei..."
+
}
+
```
+
+
**Optional fields**:
+
- `avatar`: Blob reference to avatar image
+
- `configSchema`: JSON Schema for community-specific configuration
+
+
**Wait 5-10 seconds** for Jetstream to index your service declaration into the `aggregators` table.
+
+
## Authorization Process
+
+
Before you can post to a community, a moderator must authorize your aggregator.
+
+
### How Authorization Works
+
+
1. **Moderator decision**: Community moderator evaluates your aggregator
+
2. **Authorization record**: Moderator writes `social.coves.aggregator.authorization` to community's repo
+
3. **Jetstream indexing**: Record gets indexed into `aggregator_authorizations` table
+
4. **Posting enabled**: You can now post to that community
+
+
### Authorization Record Structure
+
+
**Location**: `at://{community_did}/social.coves.aggregator.authorization/{rkey}`
+
+
**Example**:
+
```json
+
{
+
"$type": "social.coves.aggregator.authorization",
+
"aggregatorDid": "did:plc:abc123...",
+
"communityDid": "did:plc:community123...",
+
"enabled": true,
+
"createdBy": "did:plc:moderator...",
+
"createdAt": "2024-01-15T12:00:00Z",
+
"config": {
+
"maxPostsPerHour": 5,
+
"allowedCategories": ["tech", "news"]
+
}
+
}
+
```
+
+
### Checking Your Authorizations
+
+
**Endpoint**: `GET /xrpc/social.coves.aggregator.getAuthorizations`
+
+
```bash
+
curl "https://api.coves.social/xrpc/social.coves.aggregator.getAuthorizations?aggregatorDid=did:plc:abc123...&enabledOnly=true"
+
```
+
+
**Response**:
+
```json
+
{
+
"authorizations": [
+
{
+
"aggregatorDid": "did:plc:abc123...",
+
"communityDid": "did:plc:community123...",
+
"communityHandle": "~tech@coves.social",
+
"enabled": true,
+
"createdAt": "2024-01-15T12:00:00Z",
+
"config": {...}
+
}
+
]
+
}
+
```
+
+
## Posting to Communities
+
+
Once authorized, you can post to communities using the standard post creation endpoint.
+
+
### Create Post
+
+
**Endpoint**: `POST /xrpc/social.coves.community.post.create`
+
+
**Request**:
+
```bash
+
curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \
+
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
+
-H "Content-Type: application/json" \
+
-d '{
+
"communityDid": "did:plc:community123...",
+
"post": {
+
"text": "New blog post: Understanding atProto Identity\nhttps://example.com/post",
+
"createdAt": "2024-01-15T12:00:00Z",
+
"facets": [
+
{
+
"index": { "byteStart": 50, "byteEnd": 75 },
+
"features": [
+
{
+
"$type": "social.coves.richtext.facet#link",
+
"uri": "https://example.com/post"
+
}
+
]
+
}
+
]
+
}
+
}'
+
```
+
+
**Response**:
+
```json
+
{
+
"uri": "at://did:plc:abc123.../social.coves.community.post/3k...",
+
"cid": "bafyrei..."
+
}
+
```
+
+
### Post Validation
+
+
The handler validates:
+
1. **Authentication**: Valid JWT token
+
2. **Author exists**: DID in `users` table
+
3. **Is aggregator**: DID in `aggregators` table
+
4. **Authorization**: Active authorization for (aggregator, community)
+
5. **Rate limit**: Less than 10 posts/hour to this community
+
6. **Content**: Valid post structure per lexicon
+
+
### Rate Limits
+
+
**Per-community rate limit**: 10 posts per hour
+
+
This is tracked in the `aggregator_posts` table and enforced at the handler level.
+
+
**Why?**: Prevents spam while allowing useful bot activity.
+
+
**Best practices**:
+
- Batch similar content
+
- Post only high-quality content
+
- Respect community guidelines
+
- Monitor your posting rate
+
+
## Security Best Practices
+
+
### Credential Management
+
+
โœ… **DO**:
+
- Store credentials in environment variables or secret management
+
- Use HTTPS for all API calls
+
- Rotate access tokens regularly (use refresh tokens)
+
- Keep `aggregator-config.env` out of version control
+
+
โŒ **DON'T**:
+
- Hardcode credentials in source code
+
- Commit credentials to Git
+
- Share access tokens publicly
+
- Reuse personal credentials for bots
+
+
### Domain Security
+
+
โœ… **DO**:
+
- Use HTTPS for `.well-known` endpoint
+
- Keep domain under your control
+
- Monitor for unauthorized changes
+
- Use DNSSEC if possible
+
+
โŒ **DON'T**:
+
- Use HTTP (will fail verification)
+
- Use shared/untrusted hosting
+
- Allow others to modify `.well-known` files
+
- Use expired SSL certificates
+
+
### Content Security
+
+
โœ… **DO**:
+
- Validate all external content before posting
+
- Sanitize URLs and text
+
- Rate-limit your own posting
+
- Implement circuit breakers for failures
+
+
โŒ **DON'T**:
+
- Post unvalidated user input
+
- Include malicious links
+
- Spam communities
+
- Bypass rate limits
+
+
## Troubleshooting
+
+
### Registration Errors
+
+
#### Error: "DomainVerificationFailed"
+
+
**Cause**: `.well-known/atproto-did` not accessible or contains wrong DID
+
+
**Solutions**:
+
1. Verify file is accessible: `curl https://yourdomain.com/.well-known/atproto-did`
+
2. Check content matches your DID exactly (no extra whitespace)
+
3. Ensure HTTPS is working (not HTTP)
+
4. Check web server logs for access errors
+
5. Verify firewall rules allow HTTPS traffic
+
+
#### Error: "AlreadyRegistered"
+
+
**Cause**: This DID is already registered with this Coves instance
+
+
**Solutions**:
+
- This is safe to ignore if you're re-running setup
+
- If you need to update info, just create a new service declaration
+
- Contact instance admin if you need to remove registration
+
+
#### Error: "DIDResolutionFailed"
+
+
**Cause**: Could not resolve DID document from PLC directory
+
+
**Solutions**:
+
1. Verify DID exists: `curl https://plc.directory/{your-did}`
+
2. Wait 30 seconds and retry (PLC propagation delay)
+
3. Check PDS is accessible
+
4. Verify DID format is correct (must start with `did:plc:` or `did:web:`)
+
+
### Posting Errors
+
+
#### Error: "NotAuthorized"
+
+
**Cause**: No active authorization for this (aggregator, community) pair
+
+
**Solutions**:
+
1. Check authorizations: `GET /xrpc/social.coves.aggregator.getAuthorizations`
+
2. Contact community moderator to request authorization
+
3. Verify authorization wasn't disabled
+
4. Wait for Jetstream to index authorization (5-10 seconds)
+
+
#### Error: "RateLimitExceeded"
+
+
**Cause**: Exceeded 10 posts/hour to this community
+
+
**Solutions**:
+
1. Wait for the rate limit window to reset
+
2. Batch posts to stay under limit
+
3. Distribute posts across multiple communities
+
4. Implement posting queue in your aggregator
+
+
### Service Declaration Not Appearing
+
+
**Symptoms**: Service declaration created but not in `aggregators` table
+
+
**Solutions**:
+
1. Wait 5-10 seconds for Jetstream to index
+
2. Check Jetstream consumer logs for errors
+
3. Verify record was created: Check PDS at `at://your-did/social.coves.aggregator.service/self`
+
4. Verify `$type` field is exactly `"social.coves.aggregator.service"`
+
5. Check `displayName` is not empty (required field)
+
+
## API Reference
+
+
### Registration Endpoint
+
+
**`POST /xrpc/social.coves.aggregator.register`**
+
+
**Input**:
+
```typescript
+
{
+
did: string // DID of aggregator (did:plc or did:web)
+
domain: string // Domain serving .well-known/atproto-did
+
}
+
```
+
+
**Output**:
+
```typescript
+
{
+
did: string // Registered DID
+
handle: string // Handle from DID document
+
message: string // Next steps message
+
}
+
```
+
+
**Errors**:
+
- `InvalidDID`: DID format invalid
+
- `DomainVerificationFailed`: .well-known verification failed
+
- `AlreadyRegistered`: DID already registered
+
- `DIDResolutionFailed`: Could not resolve DID
+
+
### Query Endpoints
+
+
**`GET /xrpc/social.coves.aggregator.getServices`**
+
+
Get aggregator service details.
+
+
**Parameters**:
+
- `dids`: Array of DIDs (comma-separated)
+
+
**`GET /xrpc/social.coves.aggregator.getAuthorizations`**
+
+
List communities that authorized an aggregator.
+
+
**Parameters**:
+
- `aggregatorDid`: Aggregator DID
+
- `enabledOnly`: Filter to enabled only (default: false)
+
+
**`GET /xrpc/social.coves.aggregator.listForCommunity`**
+
+
List aggregators authorized by a community.
+
+
**Parameters**:
+
- `communityDid`: Community DID
+
- `enabledOnly`: Filter to enabled only (default: false)
+
+
## Further Reading
+
+
- [Aggregator PRD](PRD_AGGREGATORS.md) - Architecture and design decisions
+
- [atProto Guide](../../ATPROTO_GUIDE.md) - atProto fundamentals
+
- [Communities PRD](../PRD_COMMUNITIES.md) - Community system overview
+
- [Setup Scripts README](../../scripts/aggregator-setup/README.md) - Script documentation
+
+
## Support
+
+
For issues or questions:
+
+
1. Check this guide's troubleshooting section
+
2. Review the PRD and architecture docs
+
3. Check Coves GitHub issues
+
4. Ask in Coves developer community
+95
scripts/aggregator-setup/1-create-pds-account.sh
···
+
#!/bin/bash
+
+
# Script: 1-create-pds-account.sh
+
# Purpose: Create a PDS account for your aggregator
+
#
+
# This script helps you create an account on a PDS (Personal Data Server).
+
# The PDS will automatically create a DID:PLC for you.
+
+
set -e
+
+
echo "================================================"
+
echo "Step 1: Create PDS Account for Your Aggregator"
+
echo "================================================"
+
echo ""
+
+
# Get PDS URL
+
read -p "Enter PDS URL (default: https://bsky.social): " PDS_URL
+
PDS_URL=${PDS_URL:-https://bsky.social}
+
+
# Get credentials
+
read -p "Enter desired handle (e.g., mynewsbot.bsky.social): " HANDLE
+
read -p "Enter email: " EMAIL
+
read -sp "Enter password: " PASSWORD
+
echo ""
+
+
# Validate inputs
+
if [ -z "$HANDLE" ] || [ -z "$EMAIL" ] || [ -z "$PASSWORD" ]; then
+
echo "Error: All fields are required"
+
exit 1
+
fi
+
+
echo ""
+
echo "Creating account on $PDS_URL..."
+
+
# Create account via com.atproto.server.createAccount
+
RESPONSE=$(curl -s -X POST "$PDS_URL/xrpc/com.atproto.server.createAccount" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"handle\": \"$HANDLE\",
+
\"email\": \"$EMAIL\",
+
\"password\": \"$PASSWORD\"
+
}")
+
+
# Check if successful
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "Error creating account:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
# Extract DID and access token
+
DID=$(echo "$RESPONSE" | jq -r '.did')
+
ACCESS_JWT=$(echo "$RESPONSE" | jq -r '.accessJwt')
+
REFRESH_JWT=$(echo "$RESPONSE" | jq -r '.refreshJwt')
+
+
if [ -z "$DID" ] || [ "$DID" = "null" ]; then
+
echo "Error: Failed to extract DID from response"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
echo ""
+
echo "โœ“ Account created successfully!"
+
echo ""
+
echo "=== Save these credentials ===="
+
echo "DID: $DID"
+
echo "Handle: $HANDLE"
+
echo "PDS URL: $PDS_URL"
+
echo "Email: $EMAIL"
+
echo "Password: [hidden]"
+
echo "Access JWT: $ACCESS_JWT"
+
echo "Refresh JWT: $REFRESH_JWT"
+
echo "==============================="
+
echo ""
+
+
# Save to config file
+
CONFIG_FILE="aggregator-config.env"
+
cat > "$CONFIG_FILE" <<EOF
+
# Aggregator Account Configuration
+
# Generated: $(date)
+
+
AGGREGATOR_DID="$DID"
+
AGGREGATOR_HANDLE="$HANDLE"
+
AGGREGATOR_PDS_URL="$PDS_URL"
+
AGGREGATOR_EMAIL="$EMAIL"
+
AGGREGATOR_PASSWORD="$PASSWORD"
+
AGGREGATOR_ACCESS_JWT="$ACCESS_JWT"
+
AGGREGATOR_REFRESH_JWT="$REFRESH_JWT"
+
EOF
+
+
echo "โœ“ Configuration saved to $CONFIG_FILE"
+
echo ""
+
echo "IMPORTANT: Keep this file secure! It contains your credentials."
+
echo ""
+
echo "Next step: Run ./2-setup-wellknown.sh"
+93
scripts/aggregator-setup/2-setup-wellknown.sh
···
+
#!/bin/bash
+
+
# Script: 2-setup-wellknown.sh
+
# Purpose: Generate .well-known/atproto-did file for domain verification
+
#
+
# This script creates the .well-known/atproto-did file that proves you own your domain.
+
# You'll need to host this file at https://yourdomain.com/.well-known/atproto-did
+
+
set -e
+
+
echo "================================================"
+
echo "Step 2: Setup .well-known/atproto-did"
+
echo "================================================"
+
echo ""
+
+
# Load config if available
+
if [ -f "aggregator-config.env" ]; then
+
source aggregator-config.env
+
echo "โœ“ Loaded configuration from aggregator-config.env"
+
echo " DID: $AGGREGATOR_DID"
+
echo ""
+
else
+
echo "Configuration file not found. Please run 1-create-pds-account.sh first."
+
exit 1
+
fi
+
+
# Get domain
+
read -p "Enter your aggregator's domain (e.g., rss-bot.example.com): " DOMAIN
+
+
if [ -z "$DOMAIN" ]; then
+
echo "Error: Domain is required"
+
exit 1
+
fi
+
+
# Save domain to config
+
echo "" >> aggregator-config.env
+
echo "AGGREGATOR_DOMAIN=\"$DOMAIN\"" >> aggregator-config.env
+
+
echo ""
+
echo "Creating .well-known directory..."
+
mkdir -p .well-known
+
+
# Create the atproto-did file
+
echo "$AGGREGATOR_DID" > .well-known/atproto-did
+
+
echo "โœ“ Created .well-known/atproto-did with content: $AGGREGATOR_DID"
+
echo ""
+
+
echo "================================================"
+
echo "Next Steps:"
+
echo "================================================"
+
echo ""
+
echo "1. Upload the .well-known directory to your web server"
+
echo " The file must be accessible at:"
+
echo " https://$DOMAIN/.well-known/atproto-did"
+
echo ""
+
echo "2. Verify it's working by running:"
+
echo " curl https://$DOMAIN/.well-known/atproto-did"
+
echo " (Should return: $AGGREGATOR_DID)"
+
echo ""
+
echo "3. Once verified, run: ./3-register-with-coves.sh"
+
echo ""
+
+
# Create nginx example
+
cat > nginx-example.conf <<EOF
+
# Example nginx configuration for serving .well-known
+
# Add this to your nginx server block:
+
+
location /.well-known/atproto-did {
+
alias /path/to/your/.well-known/atproto-did;
+
default_type text/plain;
+
add_header Access-Control-Allow-Origin *;
+
}
+
EOF
+
+
echo "โœ“ Created nginx-example.conf for reference"
+
echo ""
+
+
# Create Apache example
+
cat > apache-example.conf <<EOF
+
# Example Apache configuration for serving .well-known
+
# Add this to your Apache virtual host:
+
+
Alias /.well-known /path/to/your/.well-known
+
<Directory /path/to/your/.well-known>
+
Options None
+
AllowOverride None
+
Require all granted
+
Header set Access-Control-Allow-Origin "*"
+
</Directory>
+
EOF
+
+
echo "โœ“ Created apache-example.conf for reference"
+103
scripts/aggregator-setup/3-register-with-coves.sh
···
+
#!/bin/bash
+
+
# Script: 3-register-with-coves.sh
+
# Purpose: Register your aggregator with a Coves instance
+
#
+
# This script calls the social.coves.aggregator.register XRPC endpoint
+
# to register your aggregator DID with the Coves instance.
+
+
set -e
+
+
echo "================================================"
+
echo "Step 3: Register with Coves Instance"
+
echo "================================================"
+
echo ""
+
+
# Load config if available
+
if [ -f "aggregator-config.env" ]; then
+
source aggregator-config.env
+
echo "โœ“ Loaded configuration from aggregator-config.env"
+
echo " DID: $AGGREGATOR_DID"
+
echo " Domain: $AGGREGATOR_DOMAIN"
+
echo ""
+
else
+
echo "Configuration file not found. Please run previous scripts first."
+
exit 1
+
fi
+
+
# Validate domain is set
+
if [ -z "$AGGREGATOR_DOMAIN" ]; then
+
echo "Error: AGGREGATOR_DOMAIN not set. Please run 2-setup-wellknown.sh first."
+
exit 1
+
fi
+
+
# Get Coves instance URL
+
read -p "Enter Coves instance URL (default: https://api.coves.social): " COVES_URL
+
COVES_URL=${COVES_URL:-https://api.coves.social}
+
+
echo ""
+
echo "Verifying .well-known/atproto-did is accessible..."
+
+
# Verify .well-known is accessible
+
WELLKNOWN_URL="https://$AGGREGATOR_DOMAIN/.well-known/atproto-did"
+
WELLKNOWN_CONTENT=$(curl -s "$WELLKNOWN_URL" || echo "ERROR")
+
+
if [ "$WELLKNOWN_CONTENT" = "ERROR" ]; then
+
echo "โœ— Error: Could not access $WELLKNOWN_URL"
+
echo " Please ensure the file is uploaded and accessible."
+
exit 1
+
elif [ "$WELLKNOWN_CONTENT" != "$AGGREGATOR_DID" ]; then
+
echo "โœ— Error: .well-known/atproto-did contains wrong DID"
+
echo " Expected: $AGGREGATOR_DID"
+
echo " Got: $WELLKNOWN_CONTENT"
+
exit 1
+
fi
+
+
echo "โœ“ .well-known/atproto-did is correctly configured"
+
echo ""
+
+
echo "Registering with $COVES_URL..."
+
+
# Call registration endpoint
+
RESPONSE=$(curl -s -X POST "$COVES_URL/xrpc/social.coves.aggregator.register" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"did\": \"$AGGREGATOR_DID\",
+
\"domain\": \"$AGGREGATOR_DOMAIN\"
+
}")
+
+
# Check if successful
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Registration failed:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
# Extract response
+
REGISTERED_DID=$(echo "$RESPONSE" | jq -r '.did')
+
REGISTERED_HANDLE=$(echo "$RESPONSE" | jq -r '.handle')
+
MESSAGE=$(echo "$RESPONSE" | jq -r '.message')
+
+
if [ -z "$REGISTERED_DID" ] || [ "$REGISTERED_DID" = "null" ]; then
+
echo "โœ— Error: Unexpected response format"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
echo ""
+
echo "โœ“ Registration successful!"
+
echo ""
+
echo "=== Registration Details ===="
+
echo "DID: $REGISTERED_DID"
+
echo "Handle: $REGISTERED_HANDLE"
+
echo "Message: $MESSAGE"
+
echo "============================="
+
echo ""
+
+
# Save Coves URL to config
+
echo "" >> aggregator-config.env
+
echo "COVES_INSTANCE_URL=\"$COVES_URL\"" >> aggregator-config.env
+
+
echo "โœ“ Updated aggregator-config.env with Coves instance URL"
+
echo ""
+
echo "Next step: Run ./4-create-service-declaration.sh"
+125
scripts/aggregator-setup/4-create-service-declaration.sh
···
+
#!/bin/bash
+
+
# Script: 4-create-service-declaration.sh
+
# Purpose: Create aggregator service declaration record
+
#
+
# This script writes a social.coves.aggregator.service record to your aggregator's repository.
+
# This record contains metadata about your aggregator (name, description, etc.) and will be
+
# indexed by Coves' Jetstream consumer into the aggregators table.
+
+
set -e
+
+
echo "================================================"
+
echo "Step 4: Create Service Declaration"
+
echo "================================================"
+
echo ""
+
+
# Load config if available
+
if [ -f "aggregator-config.env" ]; then
+
source aggregator-config.env
+
echo "โœ“ Loaded configuration from aggregator-config.env"
+
echo " DID: $AGGREGATOR_DID"
+
echo " PDS URL: $AGGREGATOR_PDS_URL"
+
echo ""
+
else
+
echo "Configuration file not found. Please run previous scripts first."
+
exit 1
+
fi
+
+
# Validate required fields
+
if [ -z "$AGGREGATOR_ACCESS_JWT" ]; then
+
echo "Error: AGGREGATOR_ACCESS_JWT not set. Please run 1-create-pds-account.sh first."
+
exit 1
+
fi
+
+
echo "Enter aggregator metadata:"
+
echo ""
+
+
# Get metadata from user
+
read -p "Display Name (e.g., 'RSS News Aggregator'): " DISPLAY_NAME
+
read -p "Description: " DESCRIPTION
+
read -p "Source URL (e.g., 'https://github.com/yourname/aggregator'): " SOURCE_URL
+
read -p "Maintainer DID (your personal DID, optional): " MAINTAINER_DID
+
+
if [ -z "$DISPLAY_NAME" ]; then
+
echo "Error: Display name is required"
+
exit 1
+
fi
+
+
echo ""
+
echo "Creating service declaration record..."
+
+
# Build the service record
+
SERVICE_RECORD=$(cat <<EOF
+
{
+
"\$type": "social.coves.aggregator.service",
+
"did": "$AGGREGATOR_DID",
+
"displayName": "$DISPLAY_NAME",
+
"description": "$DESCRIPTION",
+
"sourceUrl": "$SOURCE_URL",
+
"maintainer": "$MAINTAINER_DID",
+
"createdAt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+
}
+
EOF
+
)
+
+
# Call com.atproto.repo.createRecord
+
RESPONSE=$(curl -s -X POST "$AGGREGATOR_PDS_URL/xrpc/com.atproto.repo.createRecord" \
+
-H "Authorization: Bearer $AGGREGATOR_ACCESS_JWT" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"repo\": \"$AGGREGATOR_DID\",
+
\"collection\": \"social.coves.aggregator.service\",
+
\"rkey\": \"self\",
+
\"record\": $SERVICE_RECORD
+
}")
+
+
# Check if successful
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Failed to create service declaration:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
# Extract response
+
RECORD_URI=$(echo "$RESPONSE" | jq -r '.uri')
+
RECORD_CID=$(echo "$RESPONSE" | jq -r '.cid')
+
+
if [ -z "$RECORD_URI" ] || [ "$RECORD_URI" = "null" ]; then
+
echo "โœ— Error: Unexpected response format"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
echo ""
+
echo "โœ“ Service declaration created successfully!"
+
echo ""
+
echo "=== Record Details ===="
+
echo "URI: $RECORD_URI"
+
echo "CID: $RECORD_CID"
+
echo "======================="
+
echo ""
+
+
# Save to config
+
echo "" >> aggregator-config.env
+
echo "SERVICE_DECLARATION_URI=\"$RECORD_URI\"" >> aggregator-config.env
+
echo "SERVICE_DECLARATION_CID=\"$RECORD_CID\"" >> aggregator-config.env
+
+
echo "โœ“ Updated aggregator-config.env"
+
echo ""
+
echo "================================================"
+
echo "Setup Complete!"
+
echo "================================================"
+
echo ""
+
echo "Your aggregator is now registered with Coves!"
+
echo ""
+
echo "Next steps:"
+
echo "1. Wait a few seconds for Jetstream to index your service declaration"
+
echo "2. Verify your aggregator appears in the aggregators list"
+
echo "3. Community moderators can now authorize your aggregator"
+
echo "4. Once authorized, you can start posting to communities"
+
echo ""
+
echo "To test posting, use the Coves XRPC endpoint:"
+
echo " POST $COVES_INSTANCE_URL/xrpc/social.coves.community.post.create"
+
echo ""
+
echo "See docs/aggregators/SETUP_GUIDE.md for more information"
+252
scripts/aggregator-setup/README.md
···
+
# Aggregator Setup Scripts
+
+
This directory contains scripts to help you set up and register your aggregator with Coves instances.
+
+
## Overview
+
+
Aggregators are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers. To use aggregators with Coves, you need to:
+
+
1. Create a PDS account for your aggregator (gets you a DID)
+
2. Prove you own a domain via `.well-known/atproto-did`
+
3. Register with a Coves instance
+
4. Create a service declaration record
+
+
These scripts automate this process for you.
+
+
## Prerequisites
+
+
- **Domain ownership**: You must own a domain where you can host the `.well-known/atproto-did` file
+
- **Web server**: Ability to serve static files over HTTPS
+
- **Tools**: `curl`, `jq` (for JSON processing)
+
- **Account**: Email address for creating the PDS account
+
+
## Quick Start
+
+
### Interactive Setup (Recommended)
+
+
Run the scripts in order:
+
+
```bash
+
# Make scripts executable
+
chmod +x *.sh
+
+
# Step 1: Create PDS account
+
./1-create-pds-account.sh
+
+
# Step 2: Generate .well-known file
+
./2-setup-wellknown.sh
+
+
# Step 3: Register with Coves (after uploading .well-known)
+
./3-register-with-coves.sh
+
+
# Step 4: Create service declaration
+
./4-create-service-declaration.sh
+
```
+
+
### Automated Setup Example
+
+
For a reference implementation of automated setup, see the Kagi News aggregator at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh).
+
+
The Kagi script shows how to automate all 4 steps (with the manual .well-known upload step in between).
+
+
## Script Reference
+
+
### 1-create-pds-account.sh
+
+
**Purpose**: Creates a PDS account for your aggregator
+
+
**Prompts for**:
+
- PDS URL (default: https://bsky.social)
+
- Handle (e.g., mynewsbot.bsky.social)
+
- Email
+
- Password
+
+
**Outputs**:
+
- `aggregator-config.env` - Configuration file with DID and credentials
+
- Prints your DID and access tokens
+
+
**Notes**:
+
- Keep the config file secure! It contains your credentials
+
- The PDS automatically generates a DID:PLC for you
+
- You can use any PDS service, not just bsky.social
+
+
### 2-setup-wellknown.sh
+
+
**Purpose**: Generates the `.well-known/atproto-did` file for domain verification
+
+
**Prompts for**:
+
- Your domain (e.g., rss-bot.example.com)
+
+
**Outputs**:
+
- `.well-known/atproto-did` - File containing your DID
+
- `nginx-example.conf` - Example nginx configuration
+
- `apache-example.conf` - Example Apache configuration
+
+
**Manual step required**:
+
Upload the `.well-known` directory to your web server. The file must be accessible at:
+
```
+
https://yourdomain.com/.well-known/atproto-did
+
```
+
+
**Verify it works**:
+
```bash
+
curl https://yourdomain.com/.well-known/atproto-did
+
# Should return your DID (e.g., did:plc:abc123...)
+
```
+
+
### 3-register-with-coves.sh
+
+
**Purpose**: Registers your aggregator with a Coves instance
+
+
**Prompts for**:
+
- Coves instance URL (default: https://api.coves.social)
+
+
**Prerequisites**:
+
- `.well-known/atproto-did` must be accessible from your domain
+
- Scripts 1 and 2 must be completed
+
+
**What it does**:
+
1. Verifies your `.well-known/atproto-did` is accessible
+
2. Calls `social.coves.aggregator.register` XRPC endpoint
+
3. Coves verifies domain ownership
+
4. Inserts your aggregator into the `users` table
+
+
**Outputs**:
+
- Updates `aggregator-config.env` with Coves instance URL
+
- Prints registration confirmation
+
+
### 4-create-service-declaration.sh
+
+
**Purpose**: Creates the service declaration record in your repository
+
+
**Prompts for**:
+
- Display name (e.g., "RSS News Aggregator")
+
- Description
+
- Source URL (GitHub repo, etc.)
+
- Maintainer DID (optional)
+
+
**What it does**:
+
1. Creates a `social.coves.aggregator.service` record at `at://your-did/social.coves.aggregator.service/self`
+
2. Jetstream consumer will index this into the `aggregators` table
+
3. Communities can now discover and authorize your aggregator
+
+
**Outputs**:
+
- Updates `aggregator-config.env` with record URI and CID
+
- Prints record details
+
+
## Configuration File
+
+
After running the scripts, you'll have an `aggregator-config.env` file with:
+
+
```bash
+
AGGREGATOR_DID="did:plc:..."
+
AGGREGATOR_HANDLE="mynewsbot.bsky.social"
+
AGGREGATOR_PDS_URL="https://bsky.social"
+
AGGREGATOR_EMAIL="bot@example.com"
+
AGGREGATOR_PASSWORD="..."
+
AGGREGATOR_ACCESS_JWT="..."
+
AGGREGATOR_REFRESH_JWT="..."
+
AGGREGATOR_DOMAIN="rss-bot.example.com"
+
COVES_INSTANCE_URL="https://api.coves.social"
+
SERVICE_DECLARATION_URI="at://did:plc:.../social.coves.aggregator.service/self"
+
SERVICE_DECLARATION_CID="..."
+
```
+
+
**Use this in your aggregator code** to authenticate and post.
+
+
## What Happens Next?
+
+
After completing all 4 steps:
+
+
1. **Your aggregator is registered** in the Coves instance's `users` table
+
2. **Your service declaration is indexed** in the `aggregators` table (takes a few seconds)
+
3. **Community moderators can now authorize** your aggregator for their communities
+
4. **Once authorized**, your aggregator can post to those communities
+
+
## Creating an Authorization
+
+
Authorizations are created by community moderators, not by aggregators. The moderator writes a `social.coves.aggregator.authorization` record to their community's repository.
+
+
See `docs/aggregators/SETUP_GUIDE.md` for more information on the authorization process.
+
+
## Posting to Communities
+
+
Once authorized, your aggregator can post using:
+
+
```bash
+
curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \
+
-H "Authorization: Bearer $AGGREGATOR_ACCESS_JWT" \
+
-H "Content-Type: application/json" \
+
-d '{
+
"communityDid": "did:plc:...",
+
"post": {
+
"text": "Your post content",
+
"createdAt": "2024-01-15T12:00:00Z"
+
}
+
}'
+
```
+
+
## Troubleshooting
+
+
### Error: "DomainVerificationFailed"
+
+
- Verify `.well-known/atproto-did` is accessible: `curl https://yourdomain.com/.well-known/atproto-did`
+
- Check the content matches your DID exactly (no extra whitespace)
+
- Ensure HTTPS is working (not HTTP)
+
- Check CORS headers if accessing from browser
+
+
### Error: "AlreadyRegistered"
+
+
- You've already registered this DID with this Coves instance
+
- This is safe to ignore if you're re-running the setup
+
+
### Error: "DIDResolutionFailed"
+
+
- Your DID might be invalid or not found in the PLC directory
+
- Verify your DID exists: `curl https://plc.directory/<your-did>`
+
- Wait a few seconds and try again (PLC directory might be propagating)
+
+
### Service declaration not appearing
+
+
- Wait 5-10 seconds for Jetstream consumer to index it
+
- Check the Jetstream logs for errors
+
- Verify the record was created: Check your PDS at `at://your-did/social.coves.aggregator.service/self`
+
+
## Example: Kagi News Aggregator
+
+
For a complete reference implementation, see the Kagi News aggregator at `aggregators/kagi-news/`.
+
+
The Kagi aggregator includes an automated setup script at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh) that demonstrates how to:
+
+
- Automate the entire registration process
+
- Use environment variables for configuration
+
- Handle errors gracefully
+
- Integrate the setup into your aggregator project
+
+
This shows how you can package scripts 1-4 into a single automated flow for your specific aggregator.
+
+
## Security Notes
+
+
- **Never commit `aggregator-config.env`** to version control
+
- Store credentials securely (use environment variables or secret management)
+
- Rotate access tokens regularly
+
- Use HTTPS for all API calls
+
- Validate community authorization before posting
+
+
## More Information
+
+
- [Aggregator Setup Guide](../../docs/aggregators/SETUP_GUIDE.md)
+
- [Aggregator PRD](../../docs/aggregators/PRD_AGGREGATORS.md)
+
- [atProto Identity Guide](../../ATPROTO_GUIDE.md)
+
- [Coves Communities PRD](../../docs/PRD_COMMUNITIES.md)
+
+
## Support
+
+
If you encounter issues:
+
+
1. Check the troubleshooting section above
+
2. Review the full documentation in `docs/aggregators/`
+
3. Open an issue on GitHub with:
+
- Which script failed
+
- Error message
+
- Your domain (without credentials)
+188 -1
aggregators/kagi-news/README.md
···
โ”‚ โ”œโ”€โ”€ sample_rss_item.xml
โ”‚ โ””โ”€โ”€ world.xml
โ”œโ”€โ”€ scripts/
-
โ”‚ โ””โ”€โ”€ generate_did.py # Helper to generate aggregator DID (TODO)
+
โ”‚ โ””โ”€โ”€ setup.sh # Automated Coves registration script
+
โ”œโ”€โ”€ Dockerfile # Docker image definition
+
โ”œโ”€โ”€ docker-compose.yml # Docker Compose configuration
+
โ”œโ”€โ”€ docker-entrypoint.sh # Container entrypoint script
+
โ”œโ”€โ”€ .dockerignore # Docker build exclusions
โ”œโ”€โ”€ requirements.txt # Python dependencies
โ”œโ”€โ”€ config.example.yaml # Example configuration
โ”œโ”€โ”€ .env.example # Environment variables template
···
โ””โ”€โ”€ README.md
```
+
## Registration with Coves
+
+
Before running the aggregator, you must register it with a Coves instance. This creates a DID for your aggregator and registers it with Coves.
+
+
### Quick Setup (Automated)
+
+
The automated setup script handles the entire registration process:
+
+
```bash
+
cd scripts
+
chmod +x setup.sh
+
./setup.sh
+
```
+
+
This will:
+
1. **Create a PDS account** for your aggregator (generates a DID)
+
2. **Generate `.well-known/atproto-did`** file for domain verification
+
3. **Pause for manual upload** - you'll upload the file to your web server
+
4. **Register with Coves** instance via XRPC
+
5. **Create service declaration** record (indexed by Jetstream)
+
+
**Manual step required:** During the process, you'll need to upload the `.well-known/atproto-did` file to your domain so it's accessible at `https://yourdomain.com/.well-known/atproto-did`.
+
+
After completion, you'll have a `kagi-aggregator-config.env` file with:
+
- Aggregator DID and credentials
+
- Access/refresh JWTs
+
- Service declaration URI
+
+
**Keep this file secure!** It contains your aggregator's credentials.
+
+
### Manual Setup (Step-by-step)
+
+
Alternatively, use the generic setup scripts from the main Coves repo for more control:
+
+
```bash
+
# From the Coves project root
+
cd scripts/aggregator-setup
+
+
# Follow the 4-step process
+
./1-create-pds-account.sh
+
./2-setup-wellknown.sh
+
./3-register-with-coves.sh
+
./4-create-service-declaration.sh
+
```
+
+
See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed documentation on each step.
+
+
### What Happens During Registration?
+
+
1. **PDS Account Creation**: Your aggregator gets a `did:plc:...` identifier
+
2. **Domain Verification**: Proves you control your aggregator's domain
+
3. **Coves Registration**: Inserts your DID into the Coves instance's `users` table
+
4. **Service Declaration**: Creates a record that gets indexed into the `aggregators` table
+
5. **Ready for Authorization**: Community moderators can now authorize your aggregator
+
+
Once registered and authorized by a community, your aggregator can post content.
+
## Setup
### Prerequisites
- Python 3.11+
- python3-venv package (`apt install python3.12-venv`)
+
- **Completed registration** (see above)
### Installation
···
pytest --cov=src --cov-report=html
```
+
## Deployment
+
+
### Docker Deployment (Recommended for Production)
+
+
The easiest way to deploy the Kagi aggregator is using Docker. The cron job runs inside the container automatically.
+
+
#### Prerequisites
+
+
- Docker and Docker Compose installed
+
- Completed registration (you have `.env` with credentials)
+
- `config.yaml` configured with your feed mappings
+
+
#### Quick Start
+
+
1. **Configure your environment:**
+
```bash
+
# Copy and edit configuration
+
cp config.example.yaml config.yaml
+
cp .env.example .env
+
+
# Edit .env with your aggregator credentials
+
nano .env
+
```
+
+
2. **Start the aggregator:**
+
```bash
+
docker compose up -d
+
```
+
+
3. **View logs:**
+
```bash
+
docker compose logs -f
+
```
+
+
4. **Stop the aggregator:**
+
```bash
+
docker compose down
+
```
+
+
#### Configuration
+
+
The `docker-compose.yml` file supports these environment variables:
+
+
- **`AGGREGATOR_HANDLE`** (required): Your aggregator's handle
+
- **`AGGREGATOR_PASSWORD`** (required): Your aggregator's password
+
- **`COVES_API_URL`** (optional): Override Coves API endpoint (defaults to `https://api.coves.social`)
+
- **`RUN_ON_STARTUP`** (optional): Set to `true` to run immediately on container start (useful for testing)
+
+
#### Testing the Setup
+
+
Run the aggregator immediately without waiting for cron:
+
+
```bash
+
# Run once and exit
+
docker compose run --rm kagi-aggregator python -m src.main
+
+
# Or set RUN_ON_STARTUP=true in .env and restart
+
docker compose restart
+
```
+
+
#### Production Deployment
+
+
For production, consider:
+
+
1. **Using Docker Secrets** for credentials:
+
```yaml
+
secrets:
+
aggregator_credentials:
+
file: ./secrets/aggregator.env
+
```
+
+
2. **Setting up log rotation** (already configured in docker-compose.yml):
+
- Max size: 10MB per file
+
- Max files: 3
+
+
3. **Monitoring health checks:**
+
```bash
+
docker inspect --format='{{.State.Health.Status}}' kagi-news-aggregator
+
```
+
+
4. **Auto-restart on failure** (already enabled with `restart: unless-stopped`)
+
+
#### Viewing Cron Logs
+
+
```bash
+
# Follow cron execution logs
+
docker compose logs -f kagi-aggregator
+
+
# View last 100 lines
+
docker compose logs --tail=100 kagi-aggregator
+
```
+
+
#### Updating the Aggregator
+
+
```bash
+
# Pull latest code
+
git pull
+
+
# Rebuild and restart
+
docker compose up -d --build
+
```
+
+
### Manual Deployment (Alternative)
+
+
If you prefer running without Docker, use the traditional approach:
+
+
1. **Install dependencies:**
+
```bash
+
python3 -m venv venv
+
source venv/bin/activate
+
pip install -r requirements.txt
+
```
+
+
2. **Configure crontab:**
+
```bash
+
# Edit the crontab file with your paths
+
# Then install it:
+
crontab crontab
+
```
+
+
3. **Verify cron is running:**
+
```bash
+
crontab -l
+
```
+
## Development Status
### โœ… Phase 1-2 Complete (Oct 24, 2025)
+195
aggregators/kagi-news/scripts/setup.sh
···
+
#!/bin/bash
+
+
# Script: setup-kagi-aggregator.sh
+
# Purpose: Complete setup script for Kagi News RSS aggregator
+
#
+
# This is a reference implementation showing automated setup for a specific aggregator.
+
# Other aggregator developers can use this as a template.
+
+
set -e
+
+
echo "================================================"
+
echo "Kagi News RSS Aggregator - Automated Setup"
+
echo "================================================"
+
echo ""
+
+
# Configuration for Kagi aggregator
+
AGGREGATOR_NAME="kagi-news-bot"
+
DISPLAY_NAME="Kagi News RSS"
+
DESCRIPTION="Aggregates tech news from Kagi RSS feeds and posts to relevant communities"
+
SOURCE_URL="https://github.com/coves-social/kagi-aggregator"
+
+
# Check if config already exists
+
if [ -f "kagi-aggregator-config.env" ]; then
+
echo "Configuration file already exists. Loading existing configuration..."
+
source kagi-aggregator-config.env
+
SKIP_ACCOUNT_CREATION=true
+
else
+
SKIP_ACCOUNT_CREATION=false
+
fi
+
+
# Get runtime configuration
+
if [ "$SKIP_ACCOUNT_CREATION" = false ]; then
+
read -p "Enter PDS URL (default: https://bsky.social): " PDS_URL
+
PDS_URL=${PDS_URL:-https://bsky.social}
+
+
read -p "Enter email for bot account: " EMAIL
+
read -sp "Enter password for bot account: " PASSWORD
+
echo ""
+
+
# Generate handle
+
TIMESTAMP=$(date +%s)
+
HANDLE="$AGGREGATOR_NAME-$TIMESTAMP.bsky.social"
+
+
echo ""
+
echo "Creating PDS account..."
+
echo "Handle: $HANDLE"
+
+
# Create account
+
RESPONSE=$(curl -s -X POST "$PDS_URL/xrpc/com.atproto.server.createAccount" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"handle\": \"$HANDLE\",
+
\"email\": \"$EMAIL\",
+
\"password\": \"$PASSWORD\"
+
}")
+
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Error creating account:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
DID=$(echo "$RESPONSE" | jq -r '.did')
+
ACCESS_JWT=$(echo "$RESPONSE" | jq -r '.accessJwt')
+
REFRESH_JWT=$(echo "$RESPONSE" | jq -r '.refreshJwt')
+
+
echo "โœ“ Account created: $DID"
+
+
# Save configuration
+
cat > kagi-aggregator-config.env <<EOF
+
# Kagi Aggregator Configuration
+
AGGREGATOR_DID="$DID"
+
AGGREGATOR_HANDLE="$HANDLE"
+
AGGREGATOR_PDS_URL="$PDS_URL"
+
AGGREGATOR_EMAIL="$EMAIL"
+
AGGREGATOR_PASSWORD="$PASSWORD"
+
AGGREGATOR_ACCESS_JWT="$ACCESS_JWT"
+
AGGREGATOR_REFRESH_JWT="$REFRESH_JWT"
+
EOF
+
+
echo "โœ“ Configuration saved to kagi-aggregator-config.env"
+
fi
+
+
# Get domain and Coves instance
+
read -p "Enter aggregator domain (e.g., kagi-news.example.com): " DOMAIN
+
read -p "Enter Coves instance URL (default: https://api.coves.social): " COVES_URL
+
COVES_URL=${COVES_URL:-https://api.coves.social}
+
+
# Setup .well-known
+
echo ""
+
echo "Setting up .well-known/atproto-did..."
+
mkdir -p .well-known
+
echo "$DID" > .well-known/atproto-did
+
echo "โœ“ Created .well-known/atproto-did"
+
+
echo ""
+
echo "================================================"
+
echo "IMPORTANT: Manual Step Required"
+
echo "================================================"
+
echo ""
+
echo "Upload the .well-known directory to your web server at:"
+
echo " https://$DOMAIN/.well-known/atproto-did"
+
echo ""
+
read -p "Press Enter when the file is uploaded and accessible..."
+
+
# Verify .well-known
+
echo ""
+
echo "Verifying .well-known/atproto-did..."
+
WELLKNOWN_CONTENT=$(curl -s "https://$DOMAIN/.well-known/atproto-did" || echo "ERROR")
+
+
if [ "$WELLKNOWN_CONTENT" != "$DID" ]; then
+
echo "โœ— Error: .well-known/atproto-did not accessible or contains wrong DID"
+
echo " Expected: $DID"
+
echo " Got: $WELLKNOWN_CONTENT"
+
exit 1
+
fi
+
+
echo "โœ“ .well-known/atproto-did verified"
+
+
# Register with Coves
+
echo ""
+
echo "Registering with Coves instance..."
+
RESPONSE=$(curl -s -X POST "$COVES_URL/xrpc/social.coves.aggregator.register" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"did\": \"$DID\",
+
\"domain\": \"$DOMAIN\"
+
}")
+
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Registration failed:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
echo "โœ“ Registered with Coves"
+
+
# Create service declaration
+
echo ""
+
echo "Creating service declaration..."
+
SERVICE_RECORD=$(cat <<EOF
+
{
+
"\$type": "social.coves.aggregator.service",
+
"did": "$DID",
+
"displayName": "$DISPLAY_NAME",
+
"description": "$DESCRIPTION",
+
"sourceUrl": "$SOURCE_URL",
+
"createdAt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+
}
+
EOF
+
)
+
+
RESPONSE=$(curl -s -X POST "$PDS_URL/xrpc/com.atproto.repo.createRecord" \
+
-H "Authorization: Bearer $ACCESS_JWT" \
+
-H "Content-Type: application/json" \
+
-d "{
+
\"repo\": \"$DID\",
+
\"collection\": \"social.coves.aggregator.service\",
+
\"rkey\": \"self\",
+
\"record\": $SERVICE_RECORD
+
}")
+
+
if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
+
echo "โœ— Failed to create service declaration:"
+
echo "$RESPONSE" | jq '.'
+
exit 1
+
fi
+
+
RECORD_URI=$(echo "$RESPONSE" | jq -r '.uri')
+
echo "โœ“ Service declaration created: $RECORD_URI"
+
+
# Save final configuration
+
cat >> kagi-aggregator-config.env <<EOF
+
+
# Setup completed on $(date)
+
AGGREGATOR_DOMAIN="$DOMAIN"
+
COVES_INSTANCE_URL="$COVES_URL"
+
SERVICE_DECLARATION_URI="$RECORD_URI"
+
EOF
+
+
echo ""
+
echo "================================================"
+
echo "โœ“ Kagi Aggregator Setup Complete!"
+
echo "================================================"
+
echo ""
+
echo "Configuration saved to: kagi-aggregator-config.env"
+
echo ""
+
echo "Your aggregator is now registered and ready to use."
+
echo ""
+
echo "Next steps:"
+
echo "1. Start your aggregator bot: npm start (or appropriate command)"
+
echo "2. Community moderators can authorize your aggregator"
+
echo "3. Once authorized, your bot can start posting"
+
echo ""
+
echo "See docs/aggregators/SETUP_GUIDE.md for more information"
+55
aggregators/kagi-news/.dockerignore
···
+
# Git
+
.git
+
.gitignore
+
+
# Python
+
__pycache__
+
*.py[cod]
+
*$py.class
+
*.so
+
.Python
+
venv/
+
*.egg-info
+
dist/
+
build/
+
+
# Testing
+
.pytest_cache/
+
.coverage
+
htmlcov/
+
.tox/
+
.hypothesis/
+
+
# IDE
+
.vscode/
+
.idea/
+
*.swp
+
*.swo
+
*~
+
+
# Environment
+
.env.local
+
.env.*.local
+
+
# Data and logs
+
data/
+
*.log
+
+
# Documentation
+
README.md
+
docs/
+
+
# Docker
+
Dockerfile
+
docker-compose.yml
+
.dockerignore
+
+
# Development
+
tests/
+
pytest.ini
+
mypy.ini
+
.mypy_cache/
+
+
# OS
+
.DS_Store
+
Thumbs.db
+53
aggregators/kagi-news/Dockerfile
···
+
# Kagi News RSS Aggregator
+
# Production-ready Docker image with cron scheduler
+
+
FROM python:3.11-slim
+
+
# Install cron and other utilities
+
RUN apt-get update && apt-get install -y \
+
cron \
+
curl \
+
procps \
+
&& rm -rf /var/lib/apt/lists/*
+
+
# Set working directory
+
WORKDIR /app
+
+
# Copy requirements first for better caching
+
COPY requirements.txt .
+
+
# Install Python dependencies (exclude dev/test deps in production)
+
RUN pip install --no-cache-dir \
+
feedparser==6.0.11 \
+
beautifulsoup4==4.12.3 \
+
requests==2.31.0 \
+
atproto==0.0.55 \
+
pyyaml==6.0.1
+
+
# Copy application code
+
COPY src/ ./src/
+
COPY config.yaml ./
+
+
# Copy crontab file
+
COPY crontab /etc/cron.d/kagi-aggregator
+
+
# Give execution rights on the cron job and apply it
+
RUN chmod 0644 /etc/cron.d/kagi-aggregator && \
+
crontab /etc/cron.d/kagi-aggregator
+
+
# Create log file to be able to run tail
+
RUN touch /var/log/cron.log
+
+
# Copy entrypoint script
+
COPY docker-entrypoint.sh /usr/local/bin/
+
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+
# Health check - verify cron is running
+
HEALTHCHECK --interval=60s --timeout=10s --start-period=10s --retries=3 \
+
CMD pgrep cron || exit 1
+
+
# Run the entrypoint script
+
ENTRYPOINT ["docker-entrypoint.sh"]
+
+
# Default command: tail the cron log
+
CMD ["tail", "-f", "/var/log/cron.log"]
+48
aggregators/kagi-news/docker-compose.yml
···
+
services:
+
kagi-aggregator:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
container_name: kagi-news-aggregator
+
restart: unless-stopped
+
+
# Environment variables - override in .env file or here
+
environment:
+
# Required: Aggregator credentials
+
- AGGREGATOR_HANDLE=${AGGREGATOR_HANDLE}
+
- AGGREGATOR_PASSWORD=${AGGREGATOR_PASSWORD}
+
+
# Optional: Override Coves API URL
+
- COVES_API_URL=${COVES_API_URL:-https://api.coves.social}
+
+
# Optional: Run immediately on startup (useful for testing)
+
- RUN_ON_STARTUP=${RUN_ON_STARTUP:-false}
+
+
# Mount config file if you want to modify it without rebuilding
+
volumes:
+
- ./config.yaml:/app/config.yaml:ro
+
- ./data:/app/data # For state persistence (if implemented)
+
+
# Use env_file to load credentials from .env
+
env_file:
+
- .env
+
+
# Logging configuration
+
logging:
+
driver: "json-file"
+
options:
+
max-size: "10m"
+
max-file: "3"
+
+
# Health check
+
healthcheck:
+
test: ["CMD", "pgrep", "cron"]
+
interval: 60s
+
timeout: 10s
+
retries: 3
+
start_period: 10s
+
+
# Optional: Networks for multi-container setups
+
# networks:
+
# coves:
+
# external: true
+41
aggregators/kagi-news/docker-entrypoint.sh
···
+
#!/bin/bash
+
set -e
+
+
echo "Starting Kagi News RSS Aggregator..."
+
echo "========================================="
+
+
# Load environment variables if .env file exists
+
if [ -f /app/.env ]; then
+
echo "Loading environment variables from .env"
+
export $(grep -v '^#' /app/.env | xargs)
+
fi
+
+
# Validate required environment variables
+
if [ -z "$AGGREGATOR_HANDLE" ] || [ -z "$AGGREGATOR_PASSWORD" ]; then
+
echo "ERROR: Missing required environment variables!"
+
echo "Please set AGGREGATOR_HANDLE and AGGREGATOR_PASSWORD"
+
exit 1
+
fi
+
+
echo "Aggregator Handle: $AGGREGATOR_HANDLE"
+
echo "Cron schedule loaded from /etc/cron.d/kagi-aggregator"
+
+
# Start cron in the background
+
echo "Starting cron daemon..."
+
cron
+
+
# Optional: Run aggregator immediately on startup (for testing)
+
if [ "$RUN_ON_STARTUP" = "true" ]; then
+
echo "Running aggregator immediately (RUN_ON_STARTUP=true)..."
+
cd /app && python -m src.main
+
fi
+
+
echo "========================================="
+
echo "Kagi News Aggregator is running!"
+
echo "Cron schedule: Daily at 1 PM UTC"
+
echo "Logs will appear below:"
+
echo "========================================="
+
echo ""
+
+
# Execute the command passed to docker run (defaults to tail -f /var/log/cron.log)
+
exec "$@"
+20
.beads/.gitignore
···
+
# SQLite databases
+
*.db
+
*.db-journal
+
*.db-wal
+
*.db-shm
+
+
# Daemon runtime files
+
daemon.lock
+
daemon.log
+
daemon.pid
+
bd.sock
+
+
# Legacy database files
+
db.sqlite
+
bd.db
+
+
# Keep JSONL exports and config (source of truth for git)
+
!*.jsonl
+
!metadata.json
+
!config.json
+56
.beads/config.yaml
···
+
# Beads Configuration File
+
# This file configures default behavior for all bd commands in this repository
+
# All settings can also be set via environment variables (BD_* prefix)
+
# or overridden with command-line flags
+
+
# Issue prefix for this repository (used by bd init)
+
# If not set, bd init will auto-detect from directory name
+
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
+
# issue-prefix: ""
+
+
# Use no-db mode: load from JSONL, no SQLite, write back after each command
+
# When true, bd will use .beads/issues.jsonl as the source of truth
+
# instead of SQLite database
+
# no-db: false
+
+
# Disable daemon for RPC communication (forces direct database access)
+
# no-daemon: false
+
+
# Disable auto-flush of database to JSONL after mutations
+
# no-auto-flush: false
+
+
# Disable auto-import from JSONL when it's newer than database
+
# no-auto-import: false
+
+
# Enable JSON output by default
+
# json: false
+
+
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
+
# actor: ""
+
+
# Path to database (overridden by BEADS_DB or --db)
+
# db: ""
+
+
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
+
# auto-start-daemon: true
+
+
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
+
# flush-debounce: "5s"
+
+
# Multi-repo configuration (experimental - bd-307)
+
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
+
# repos:
+
# primary: "." # Primary repo (where this database lives)
+
# additional: # Additional repos to hydrate from (read-only)
+
# - ~/beads-planning # Personal planning repo
+
# - ~/work-planning # Work planning repo
+
+
# Integration settings (access with 'bd config get/set')
+
# These are stored in the database, not in this file:
+
# - jira.url
+
# - jira.project
+
# - linear.url
+
# - linear.api-key
+
# - github.org
+
# - github.repo
+
# - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set)
+4
.beads/metadata.json
···
+
{
+
"database": "beads.db",
+
"jsonl_export": "beads.jsonl"
+
}
+3
.gitattributes
···
+
+
# Use bd merge for beads JSONL files
+
.beads/beads.jsonl merge=beads
+131
AGENTS.md
···
+
# AI Agent Guidelines for Coves
+
+
## Issue Tracking with bd (beads)
+
+
**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.
+
+
### Why bd?
+
+
- Dependency-aware: Track blockers and relationships between issues
+
- Git-friendly: Auto-syncs to JSONL for version control
+
- Agent-optimized: JSON output, ready work detection, discovered-from links
+
- Prevents duplicate tracking systems and confusion
+
+
### Quick Start
+
+
**Check for ready work:**
+
```bash
+
bd ready --json
+
```
+
+
**Create new issues:**
+
```bash
+
bd create "Issue title" -t bug|feature|task -p 0-4 --json
+
bd create "Issue title" -p 1 --deps discovered-from:bd-123 --json
+
```
+
+
**Claim and update:**
+
```bash
+
bd update bd-42 --status in_progress --json
+
bd update bd-42 --priority 1 --json
+
```
+
+
**Complete work:**
+
```bash
+
bd close bd-42 --reason "Completed" --json
+
```
+
+
### Issue Types
+
+
- `bug` - Something broken
+
- `feature` - New functionality
+
- `task` - Work item (tests, docs, refactoring)
+
- `epic` - Large feature with subtasks
+
- `chore` - Maintenance (dependencies, tooling)
+
+
### Priorities
+
+
- `0` - Critical (security, data loss, broken builds)
+
- `1` - High (major features, important bugs)
+
- `2` - Medium (default, nice-to-have)
+
- `3` - Low (polish, optimization)
+
- `4` - Backlog (future ideas)
+
+
### Workflow for AI Agents
+
+
1. **Check ready work**: `bd ready` shows unblocked issues
+
2. **Claim your task**: `bd update <id> --status in_progress`
+
3. **Work on it**: Implement, test, document
+
4. **Discover new work?** Create linked issue:
+
- `bd create "Found bug" -p 1 --deps discovered-from:<parent-id>`
+
5. **Complete**: `bd close <id> --reason "Done"`
+
6. **Commit together**: Always commit the `.beads/issues.jsonl` file together with the code changes so issue state stays in sync with code state
+
+
### Auto-Sync
+
+
bd automatically syncs with git:
+
- Exports to `.beads/issues.jsonl` after changes (5s debounce)
+
- Imports from JSONL when newer (e.g., after `git pull`)
+
- No manual export/import needed!
+
+
### MCP Server (Recommended)
+
+
If using Claude or MCP-compatible clients, install the beads MCP server:
+
+
```bash
+
pip install beads-mcp
+
```
+
+
Add to MCP config (e.g., `~/.config/claude/config.json`):
+
```json
+
{
+
"beads": {
+
"command": "beads-mcp",
+
"args": []
+
}
+
}
+
```
+
+
Then use `mcp__beads__*` functions instead of CLI commands.
+
+
### Managing AI-Generated Planning Documents
+
+
AI assistants often create planning and design documents during development:
+
- PLAN.md, IMPLEMENTATION.md, ARCHITECTURE.md
+
- DESIGN.md, CODEBASE_SUMMARY.md, INTEGRATION_PLAN.md
+
- TESTING_GUIDE.md, TECHNICAL_DESIGN.md, and similar files
+
+
**Best Practice: Use a dedicated directory for these ephemeral files**
+
+
**Recommended approach:**
+
- Create a `history/` directory in the project root
+
- Store ALL AI-generated planning/design docs in `history/`
+
- Keep the repository root clean and focused on permanent project files
+
- Only access `history/` when explicitly asked to review past planning
+
+
**Example .gitignore entry (optional):**
+
```
+
# AI planning documents (ephemeral)
+
history/
+
```
+
+
**Benefits:**
+
- โœ… Clean repository root
+
- โœ… Clear separation between ephemeral and permanent documentation
+
- โœ… Easy to exclude from version control if desired
+
- โœ… Preserves planning history for archeological research
+
- โœ… Reduces noise when browsing the project
+
+
### Important Rules
+
+
- โœ… Use bd for ALL task tracking
+
- โœ… Always use `--json` flag for programmatic use
+
- โœ… Link discovered work with `discovered-from` dependencies
+
- โœ… Check `bd ready` before asking "what should I work on?"
+
- โœ… Store AI planning docs in `history/` directory
+
- โŒ Do NOT create markdown TODO lists
+
- โŒ Do NOT use external issue trackers
+
- โŒ Do NOT duplicate tracking systems
+
- โŒ Do NOT clutter repo root with planning documents
+
+
For more details, see the [beads repository](https://github.com/steveyegge/beads).
+5
internal/atproto/lexicon/social/coves/community/list.json
···
"type": "string",
"description": "Pagination cursor"
},
+
"visibility": {
+
"type": "string",
+
"knownValues": ["public", "unlisted", "private"],
+
"description": "Filter communities by visibility level"
+
},
"sort": {
"type": "string",
"knownValues": ["popular", "active", "new", "alphabetical"],
+8 -8
internal/core/communities/community.go
···
// ListCommunitiesRequest represents query parameters for listing communities
type ListCommunitiesRequest struct {
-
Visibility string `json:"visibility,omitempty"`
-
HostedBy string `json:"hostedBy,omitempty"`
-
SortBy string `json:"sortBy,omitempty"`
-
SortOrder string `json:"sortOrder,omitempty"`
-
Limit int `json:"limit"`
-
Offset int `json:"offset"`
+
Sort string `json:"sort,omitempty"` // Enum: popular, active, new, alphabetical
+
Visibility string `json:"visibility,omitempty"` // Filter: public, unlisted, private
+
Category string `json:"category,omitempty"` // Optional: filter by category (future)
+
Language string `json:"language,omitempty"` // Optional: filter by language (future)
+
Limit int `json:"limit"` // 1-100, default 50
+
Offset int `json:"offset"` // Pagination offset
}
// SearchCommunitiesRequest represents query parameters for searching communities
···
name := c.Handle[:communityIndex]
// Extract instance domain (everything after ".community.")
-
// len(".community.") = 11
-
instanceDomain := c.Handle[communityIndex+11:]
+
communitySegment := ".community."
+
instanceDomain := c.Handle[communityIndex+len(communitySegment):]
return fmt.Sprintf("!%s@%s", name, instanceDomain)
}
+2 -2
internal/core/communities/interfaces.go
···
UpdateCredentials(ctx context.Context, did, accessToken, refreshToken string) error
// Listing & Search
-
List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) // Returns communities + total count
+
List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, error)
Search(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error)
// Subscriptions (lightweight feed follows)
···
CreateCommunity(ctx context.Context, req CreateCommunityRequest) (*Community, error)
GetCommunity(ctx context.Context, identifier string) (*Community, error) // identifier can be DID or handle
UpdateCommunity(ctx context.Context, req UpdateCommunityRequest) (*Community, error)
-
ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error)
+
ListCommunities(ctx context.Context, req ListCommunitiesRequest) ([]*Community, error)
SearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error)
// Subscription operations (write-forward: creates record in user's PDS)
+57
scripts/backup.sh
···
+
#!/bin/bash
+
# Coves Database Backup Script
+
# Usage: ./scripts/backup.sh
+
#
+
# Creates timestamped PostgreSQL backups in ./backups/
+
# Retention: Keeps last 30 days of backups
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
BACKUP_DIR="$PROJECT_DIR/backups"
+
COMPOSE_FILE="$PROJECT_DIR/docker-compose.prod.yml"
+
+
# Load environment
+
set -a
+
source "$PROJECT_DIR/.env.prod"
+
set +a
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
NC='\033[0m'
+
+
log() { echo -e "${GREEN}[BACKUP]${NC} $1"; }
+
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+
+
# Create backup directory
+
mkdir -p "$BACKUP_DIR"
+
+
# Generate timestamp
+
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
+
BACKUP_FILE="$BACKUP_DIR/coves_${TIMESTAMP}.sql.gz"
+
+
log "Starting backup..."
+
+
# Run pg_dump inside container
+
docker compose -f "$COMPOSE_FILE" exec -T postgres \
+
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists \
+
| gzip > "$BACKUP_FILE"
+
+
# Get file size
+
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
+
+
log "โœ… Backup complete: $BACKUP_FILE ($SIZE)"
+
+
# Cleanup old backups (keep last 30 days)
+
log "Cleaning up backups older than 30 days..."
+
find "$BACKUP_DIR" -name "coves_*.sql.gz" -mtime +30 -delete
+
+
# List recent backups
+
log ""
+
log "Recent backups:"
+
ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | tail -5
+
+
log ""
+
log "To restore: gunzip -c $BACKUP_FILE | docker compose -f docker-compose.prod.yml exec -T postgres psql -U $POSTGRES_USER -d $POSTGRES_DB"
+133
scripts/deploy.sh
···
+
#!/bin/bash
+
# Coves Deployment Script
+
# Usage: ./scripts/deploy.sh [service]
+
#
+
# Examples:
+
# ./scripts/deploy.sh # Deploy all services
+
# ./scripts/deploy.sh appview # Deploy only AppView
+
# ./scripts/deploy.sh --pull # Pull from git first, then deploy
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
COMPOSE_FILE="$PROJECT_DIR/docker-compose.prod.yml"
+
+
# Colors for output
+
RED='\033[0;31m'
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
NC='\033[0m' # No Color
+
+
log() {
+
echo -e "${GREEN}[DEPLOY]${NC} $1"
+
}
+
+
warn() {
+
echo -e "${YELLOW}[WARN]${NC} $1"
+
}
+
+
error() {
+
echo -e "${RED}[ERROR]${NC} $1"
+
exit 1
+
}
+
+
# Parse arguments
+
PULL_GIT=false
+
SERVICE=""
+
+
for arg in "$@"; do
+
case $arg in
+
--pull)
+
PULL_GIT=true
+
;;
+
*)
+
SERVICE="$arg"
+
;;
+
esac
+
done
+
+
cd "$PROJECT_DIR"
+
+
# Load environment variables
+
if [ ! -f ".env.prod" ]; then
+
error ".env.prod not found! Copy from .env.prod.example and configure secrets."
+
fi
+
+
log "Loading environment from .env.prod..."
+
set -a
+
source .env.prod
+
set +a
+
+
# Optional: Pull from git
+
if [ "$PULL_GIT" = true ]; then
+
log "Pulling latest code from git..."
+
git fetch origin
+
git pull origin main
+
fi
+
+
# Check database connectivity before deployment
+
log "Checking database connectivity..."
+
if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then
+
log "Database is ready"
+
else
+
warn "Database not ready yet - it will start with the deployment"
+
fi
+
+
# Build and deploy
+
if [ -n "$SERVICE" ]; then
+
log "Building $SERVICE..."
+
docker compose -f "$COMPOSE_FILE" build --no-cache "$SERVICE"
+
+
log "Deploying $SERVICE..."
+
docker compose -f "$COMPOSE_FILE" up -d "$SERVICE"
+
else
+
log "Building all services..."
+
docker compose -f "$COMPOSE_FILE" build --no-cache
+
+
log "Deploying all services..."
+
docker compose -f "$COMPOSE_FILE" up -d
+
fi
+
+
# Health check
+
log "Waiting for services to be healthy..."
+
sleep 10
+
+
# Wait for database to be ready before running migrations
+
log "Waiting for database..."
+
for i in {1..30}; do
+
if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then
+
break
+
fi
+
sleep 1
+
done
+
+
# Run database migrations
+
# The AppView runs migrations on startup, but we can also trigger them explicitly
+
log "Running database migrations..."
+
if docker compose -f "$COMPOSE_FILE" exec -T appview /app/coves-server migrate 2>/dev/null; then
+
log "โœ… Migrations completed"
+
else
+
warn "โš ๏ธ Migration command not available or failed - AppView will run migrations on startup"
+
fi
+
+
# Check AppView health
+
if docker compose -f "$COMPOSE_FILE" exec -T appview wget --spider -q http://localhost:8080/xrpc/_health 2>/dev/null; then
+
log "โœ… AppView is healthy"
+
else
+
warn "โš ๏ธ AppView health check failed - check logs with: docker compose -f docker-compose.prod.yml logs appview"
+
fi
+
+
# Check PDS health
+
if docker compose -f "$COMPOSE_FILE" exec -T pds wget --spider -q http://localhost:3000/xrpc/_health 2>/dev/null; then
+
log "โœ… PDS is healthy"
+
else
+
warn "โš ๏ธ PDS health check failed - check logs with: docker compose -f docker-compose.prod.yml logs pds"
+
fi
+
+
log "Deployment complete!"
+
log ""
+
log "Useful commands:"
+
log " View logs: docker compose -f docker-compose.prod.yml logs -f"
+
log " Check status: docker compose -f docker-compose.prod.yml ps"
+
log " Rollback: docker compose -f docker-compose.prod.yml down && git checkout HEAD~1 && ./scripts/deploy.sh"
+149
scripts/generate-did-keys.sh
···
+
#!/bin/bash
+
# Generate cryptographic keys for Coves did:web DID document
+
#
+
# This script generates a secp256k1 (K-256) key pair as required by atproto.
+
# Reference: https://atproto.com/specs/cryptography
+
#
+
# Key format:
+
# - Curve: secp256k1 (K-256) - same as Bitcoin/Ethereum
+
# - Type: Multikey
+
# - Encoding: publicKeyMultibase with base58btc ('z' prefix)
+
# - Multicodec: 0xe7 for secp256k1 compressed public key
+
#
+
# Output:
+
# - Private key (hex) for PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX
+
# - Public key (multibase) for did.json publicKeyMultibase field
+
# - Complete did.json file
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
OUTPUT_DIR="$PROJECT_DIR/static/.well-known"
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
RED='\033[0;31m'
+
NC='\033[0m'
+
+
log() { echo -e "${GREEN}[KEYGEN]${NC} $1"; }
+
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+
+
# Check for required tools
+
if ! command -v openssl &> /dev/null; then
+
error "openssl is required but not installed"
+
fi
+
+
if ! command -v python3 &> /dev/null; then
+
error "python3 is required for base58 encoding"
+
fi
+
+
# Check for base58 library
+
if ! python3 -c "import base58" 2>/dev/null; then
+
warn "Installing base58 Python library..."
+
pip3 install base58 || error "Failed to install base58. Run: pip3 install base58"
+
fi
+
+
log "Generating secp256k1 key pair for did:web..."
+
+
# Generate private key
+
PRIVATE_KEY_PEM=$(mktemp)
+
openssl ecparam -name secp256k1 -genkey -noout -out "$PRIVATE_KEY_PEM" 2>/dev/null
+
+
# Extract private key as hex (for PDS config)
+
PRIVATE_KEY_HEX=$(openssl ec -in "$PRIVATE_KEY_PEM" -text -noout 2>/dev/null | \
+
grep -A 3 "priv:" | tail -n 3 | tr -d ' :\n' | tr -d '\r')
+
+
# Extract public key as compressed format
+
# OpenSSL outputs the public key, we need to get the compressed form
+
PUBLIC_KEY_HEX=$(openssl ec -in "$PRIVATE_KEY_PEM" -pubout -conv_form compressed -outform DER 2>/dev/null | \
+
tail -c 33 | xxd -p | tr -d '\n')
+
+
# Clean up temp file
+
rm -f "$PRIVATE_KEY_PEM"
+
+
# Encode public key as multibase with multicodec
+
# Multicodec 0xe7 = secp256k1 compressed public key
+
# Then base58btc encode with 'z' prefix
+
PUBLIC_KEY_MULTIBASE=$(python3 << EOF
+
import base58
+
+
# Compressed public key bytes
+
pub_hex = "$PUBLIC_KEY_HEX"
+
pub_bytes = bytes.fromhex(pub_hex)
+
+
# Prepend multicodec 0xe7 for secp256k1-pub
+
# 0xe7 as varint is just 0xe7 (single byte, < 128)
+
multicodec = bytes([0xe7, 0x01]) # 0xe701 for secp256k1-pub compressed
+
key_with_codec = multicodec + pub_bytes
+
+
# Base58btc encode
+
encoded = base58.b58encode(key_with_codec).decode('ascii')
+
+
# Add 'z' prefix for multibase
+
print('z' + encoded)
+
EOF
+
)
+
+
log "Keys generated successfully!"
+
echo ""
+
echo "============================================"
+
echo " PRIVATE KEY (keep secret!)"
+
echo "============================================"
+
echo ""
+
echo "Add this to your .env.prod file:"
+
echo ""
+
echo "PDS_ROTATION_KEY=$PRIVATE_KEY_HEX"
+
echo ""
+
echo "============================================"
+
echo " PUBLIC KEY (for did.json)"
+
echo "============================================"
+
echo ""
+
echo "publicKeyMultibase: $PUBLIC_KEY_MULTIBASE"
+
echo ""
+
+
# Generate the did.json file
+
log "Generating did.json..."
+
+
mkdir -p "$OUTPUT_DIR"
+
+
cat > "$OUTPUT_DIR/did.json" << EOF
+
{
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "$PUBLIC_KEY_MULTIBASE"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://coves.me"
+
}
+
]
+
}
+
EOF
+
+
log "Created: $OUTPUT_DIR/did.json"
+
echo ""
+
echo "============================================"
+
echo " NEXT STEPS"
+
echo "============================================"
+
echo ""
+
echo "1. Copy the PDS_ROTATION_KEY value to your .env.prod file"
+
echo ""
+
echo "2. Verify the did.json looks correct:"
+
echo " cat $OUTPUT_DIR/did.json"
+
echo ""
+
echo "3. After deployment, verify it's accessible:"
+
echo " curl https://coves.social/.well-known/did.json"
+
echo ""
+
warn "IMPORTANT: Keep the private key secret! Only share the public key."
+
warn "The did.json file with the public key IS safe to commit to git."
+106
scripts/setup-production.sh
···
+
#!/bin/bash
+
# Coves Production Setup Script
+
# Run this once on a fresh server to set up everything
+
#
+
# Prerequisites:
+
# - Docker and docker-compose installed
+
# - Git installed
+
# - .env.prod file configured
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
RED='\033[0;31m'
+
NC='\033[0m'
+
+
log() { echo -e "${GREEN}[SETUP]${NC} $1"; }
+
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+
+
cd "$PROJECT_DIR"
+
+
# Check prerequisites
+
log "Checking prerequisites..."
+
+
if ! command -v docker &> /dev/null; then
+
error "Docker is not installed. Install with: curl -fsSL https://get.docker.com | sh"
+
fi
+
+
if ! docker compose version &> /dev/null; then
+
error "docker compose is not available. Install with: apt install docker-compose-plugin"
+
fi
+
+
# Check for .env.prod
+
if [ ! -f ".env.prod" ]; then
+
error ".env.prod not found! Copy from .env.prod.example and configure secrets."
+
fi
+
+
# Load environment
+
set -a
+
source .env.prod
+
set +a
+
+
# Create required directories
+
log "Creating directories..."
+
mkdir -p backups
+
mkdir -p static/.well-known
+
+
# Check for did.json
+
if [ ! -f "static/.well-known/did.json" ]; then
+
warn "static/.well-known/did.json not found!"
+
warn "Run ./scripts/generate-did-keys.sh to create it."
+
fi
+
+
# Note: Caddy logs are written to Docker volume (caddy-data)
+
# If you need host-accessible logs, uncomment and run as root:
+
# mkdir -p /var/log/caddy && chown 1000:1000 /var/log/caddy
+
+
# Pull Docker images
+
log "Pulling Docker images..."
+
docker compose -f docker-compose.prod.yml pull postgres pds caddy
+
+
# Build AppView
+
log "Building AppView..."
+
docker compose -f docker-compose.prod.yml build appview
+
+
# Start services
+
log "Starting services..."
+
docker compose -f docker-compose.prod.yml up -d
+
+
# Wait for PostgreSQL
+
log "Waiting for PostgreSQL to be ready..."
+
until docker compose -f docker-compose.prod.yml exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do
+
sleep 2
+
done
+
log "PostgreSQL is ready!"
+
+
# Run migrations
+
log "Running database migrations..."
+
# The AppView runs migrations on startup, but you can also run them manually:
+
# docker compose -f docker-compose.prod.yml exec appview /app/coves-server migrate
+
+
# Final status
+
log ""
+
log "============================================"
+
log " Coves Production Setup Complete!"
+
log "============================================"
+
log ""
+
log "Services running:"
+
docker compose -f docker-compose.prod.yml ps
+
log ""
+
log "Next steps:"
+
log " 1. Configure DNS for coves.social and coves.me"
+
log " 2. Run ./scripts/generate-did-keys.sh to create DID keys"
+
log " 3. Test health endpoints:"
+
log " curl https://coves.social/xrpc/_health"
+
log " curl https://coves.me/xrpc/_health"
+
log ""
+
log "Useful commands:"
+
log " View logs: docker compose -f docker-compose.prod.yml logs -f"
+
log " Deploy update: ./scripts/deploy.sh appview"
+
log " Backup DB: ./scripts/backup.sh"
+19
static/.well-known/did.json.template
···
+
{
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "REPLACE_WITH_YOUR_PUBLIC_KEY"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://coves.me"
+
}
+
]
+
}
+18
static/client-metadata.json
···
+
{
+
"client_id": "https://coves.social/client-metadata.json",
+
"client_name": "Coves",
+
"client_uri": "https://coves.social",
+
"logo_uri": "https://coves.social/logo.png",
+
"tos_uri": "https://coves.social/terms",
+
"policy_uri": "https://coves.social/privacy",
+
"redirect_uris": [
+
"https://coves.social/oauth/callback",
+
"social.coves:/oauth/callback"
+
],
+
"scope": "atproto transition:generic",
+
"grant_types": ["authorization_code", "refresh_token"],
+
"response_types": ["code"],
+
"application_type": "native",
+
"token_endpoint_auth_method": "none",
+
"dpop_bound_access_tokens": true
+
}
+97
static/oauth/callback.html
···
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="utf-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1">
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'">
+
<title>Authorization Successful - Coves</title>
+
<style>
+
body {
+
font-family: system-ui, -apple-system, sans-serif;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
min-height: 100vh;
+
margin: 0;
+
background: #f5f5f5;
+
}
+
.container {
+
text-align: center;
+
padding: 2rem;
+
background: white;
+
border-radius: 8px;
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+
max-width: 400px;
+
}
+
.success { color: #22c55e; font-size: 3rem; margin-bottom: 1rem; }
+
h1 { margin: 0 0 0.5rem; color: #1f2937; font-size: 1.5rem; }
+
p { color: #6b7280; margin: 0.5rem 0; }
+
a {
+
display: inline-block;
+
margin-top: 1rem;
+
padding: 0.75rem 1.5rem;
+
background: #3b82f6;
+
color: white;
+
text-decoration: none;
+
border-radius: 6px;
+
font-weight: 500;
+
}
+
a:hover { background: #2563eb; }
+
</style>
+
</head>
+
<body>
+
<div class="container">
+
<div class="success">โœ“</div>
+
<h1>Authorization Successful!</h1>
+
<p id="status">Returning to Coves...</p>
+
<a href="#" id="manualLink">Open Coves</a>
+
</div>
+
<script>
+
(function() {
+
// Parse and sanitize query params - only allow expected OAuth parameters
+
const urlParams = new URLSearchParams(window.location.search);
+
const safeParams = new URLSearchParams();
+
+
// Whitelist only expected OAuth callback parameters
+
const code = urlParams.get('code');
+
const state = urlParams.get('state');
+
const error = urlParams.get('error');
+
const errorDescription = urlParams.get('error_description');
+
const iss = urlParams.get('iss');
+
+
if (code) safeParams.set('code', code);
+
if (state) safeParams.set('state', state);
+
if (error) safeParams.set('error', error);
+
if (errorDescription) safeParams.set('error_description', errorDescription);
+
if (iss) safeParams.set('iss', iss);
+
+
const sanitizedQuery = safeParams.toString() ? '?' + safeParams.toString() : '';
+
+
const userAgent = navigator.userAgent || '';
+
const isAndroid = /Android/i.test(userAgent);
+
+
// Build deep link based on platform
+
let deepLink;
+
if (isAndroid) {
+
// Android: Intent URL format
+
const pathAndQuery = '/oauth/callback' + sanitizedQuery;
+
deepLink = 'intent:/' + pathAndQuery + '#Intent;scheme=social.coves;package=social.coves;end';
+
} else {
+
// iOS: Custom scheme
+
deepLink = 'social.coves:/oauth/callback' + sanitizedQuery;
+
}
+
+
// Update manual link
+
document.getElementById('manualLink').href = deepLink;
+
+
// Attempt automatic redirect
+
window.location.href = deepLink;
+
+
// Update status after 2 seconds if redirect didn't work
+
setTimeout(function() {
+
document.getElementById('status').textContent = 'Click the button above to continue';
+
}, 2000);
+
})();
+
</script>
+
</body>
+
</html>
+2 -1
Dockerfile
···
COPY --from=builder /build/coves-server /app/coves-server
# Copy migrations (needed for goose)
-
COPY --from=builder /build/internal/db/migrations /app/migrations
+
# Must maintain path structure as app looks for internal/db/migrations
+
COPY --from=builder /build/internal/db/migrations /app/internal/db/migrations
# Set ownership
RUN chown -R coves:coves /app
+187
scripts/derive-did-from-key.sh
···
+
#!/bin/bash
+
# Derive public key from existing PDS_ROTATION_KEY and create did.json
+
#
+
# This script takes your existing private key and derives the public key from it.
+
# Use this if you already have a PDS running with a rotation key but need to
+
# create/fix the did.json file.
+
#
+
# Usage: ./scripts/derive-did-from-key.sh
+
+
set -e
+
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
OUTPUT_DIR="$PROJECT_DIR/static/.well-known"
+
+
# Colors
+
GREEN='\033[0;32m'
+
YELLOW='\033[1;33m'
+
RED='\033[0;31m'
+
NC='\033[0m'
+
+
log() { echo -e "${GREEN}[DERIVE]${NC} $1"; }
+
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+
+
# Check for required tools
+
if ! command -v openssl &> /dev/null; then
+
error "openssl is required but not installed"
+
fi
+
+
if ! command -v python3 &> /dev/null; then
+
error "python3 is required for base58 encoding"
+
fi
+
+
# Check for base58 library
+
if ! python3 -c "import base58" 2>/dev/null; then
+
warn "Installing base58 Python library..."
+
pip3 install base58 || error "Failed to install base58. Run: pip3 install base58"
+
fi
+
+
# Load environment to get the existing key
+
if [ -f "$PROJECT_DIR/.env.prod" ]; then
+
source "$PROJECT_DIR/.env.prod"
+
elif [ -f "$PROJECT_DIR/.env" ]; then
+
source "$PROJECT_DIR/.env"
+
else
+
error "No .env.prod or .env file found"
+
fi
+
+
if [ -z "$PDS_ROTATION_KEY" ]; then
+
error "PDS_ROTATION_KEY not found in environment"
+
fi
+
+
# Validate key format (should be 64 hex chars)
+
if [[ ! "$PDS_ROTATION_KEY" =~ ^[0-9a-fA-F]{64}$ ]]; then
+
error "PDS_ROTATION_KEY is not a valid 64-character hex string"
+
fi
+
+
log "Deriving public key from existing PDS_ROTATION_KEY..."
+
+
# Create a temporary PEM file from the hex private key
+
TEMP_DIR=$(mktemp -d)
+
PRIVATE_KEY_HEX="$PDS_ROTATION_KEY"
+
+
# Convert hex private key to PEM format
+
# secp256k1 curve OID: 1.3.132.0.10
+
python3 > "$TEMP_DIR/private.pem" << EOF
+
import binascii
+
+
# Private key in hex
+
priv_hex = "$PRIVATE_KEY_HEX"
+
priv_bytes = binascii.unhexlify(priv_hex)
+
+
# secp256k1 OID
+
oid = bytes([0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x0a])
+
+
# Build the EC private key structure
+
# SEQUENCE { version INTEGER, privateKey OCTET STRING, [0] OID, [1] publicKey }
+
# We'll use a simpler approach: just the private key with curve params
+
+
# EC PARAMETERS for secp256k1
+
ec_params = bytes([
+
0x30, 0x07, # SEQUENCE, 7 bytes
+
0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x0a # OID for secp256k1
+
])
+
+
# EC PRIVATE KEY structure
+
# SEQUENCE { version, privateKey, [0] parameters }
+
inner = bytes([0x02, 0x01, 0x01]) # version = 1
+
inner += bytes([0x04, 0x20]) + priv_bytes # OCTET STRING with 32-byte key
+
inner += bytes([0xa0, 0x07]) + bytes([0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x0a]) # [0] OID
+
+
# Wrap in SEQUENCE
+
key_der = bytes([0x30, len(inner)]) + inner
+
+
# Base64 encode
+
import base64
+
key_b64 = base64.b64encode(key_der).decode('ascii')
+
+
# Format as PEM
+
print("-----BEGIN EC PRIVATE KEY-----")
+
for i in range(0, len(key_b64), 64):
+
print(key_b64[i:i+64])
+
print("-----END EC PRIVATE KEY-----")
+
EOF
+
+
# Extract the compressed public key
+
PUBLIC_KEY_HEX=$(openssl ec -in "$TEMP_DIR/private.pem" -pubout -conv_form compressed -outform DER 2>/dev/null | \
+
tail -c 33 | xxd -p | tr -d '\n')
+
+
# Clean up
+
rm -rf "$TEMP_DIR"
+
+
if [ -z "$PUBLIC_KEY_HEX" ] || [ ${#PUBLIC_KEY_HEX} -ne 66 ]; then
+
error "Failed to derive public key. Got: $PUBLIC_KEY_HEX"
+
fi
+
+
log "Derived public key: ${PUBLIC_KEY_HEX:0:8}...${PUBLIC_KEY_HEX: -8}"
+
+
# Encode public key as multibase with multicodec
+
PUBLIC_KEY_MULTIBASE=$(python3 << EOF
+
import base58
+
+
# Compressed public key bytes
+
pub_hex = "$PUBLIC_KEY_HEX"
+
pub_bytes = bytes.fromhex(pub_hex)
+
+
# Prepend multicodec 0xe7 for secp256k1-pub
+
# 0xe7 as varint is just 0xe7 (single byte, < 128)
+
multicodec = bytes([0xe7, 0x01]) # 0xe701 for secp256k1-pub compressed
+
key_with_codec = multicodec + pub_bytes
+
+
# Base58btc encode
+
encoded = base58.b58encode(key_with_codec).decode('ascii')
+
+
# Add 'z' prefix for multibase
+
print('z' + encoded)
+
EOF
+
)
+
+
log "Public key multibase: $PUBLIC_KEY_MULTIBASE"
+
+
# Generate the did.json file
+
log "Generating did.json..."
+
+
mkdir -p "$OUTPUT_DIR"
+
+
cat > "$OUTPUT_DIR/did.json" << EOF
+
{
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "$PUBLIC_KEY_MULTIBASE"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://coves.me"
+
}
+
]
+
}
+
EOF
+
+
log "Created: $OUTPUT_DIR/did.json"
+
echo ""
+
echo "============================================"
+
echo " DID Document Generated Successfully!"
+
echo "============================================"
+
echo ""
+
echo "Public key multibase: $PUBLIC_KEY_MULTIBASE"
+
echo ""
+
echo "Next steps:"
+
echo " 1. Copy this file to your production server:"
+
echo " scp $OUTPUT_DIR/did.json user@server:/opt/coves/static/.well-known/"
+
echo ""
+
echo " 2. Or if running on production, restart Caddy:"
+
echo " docker compose -f docker-compose.prod.yml restart caddy"
+
echo ""
+
echo " 3. Verify it's accessible:"
+
echo " curl https://coves.social/.well-known/did.json"
+
echo ""
+3 -2
internal/api/routes/community.go
···
// RegisterCommunityRoutes registers community-related XRPC endpoints on the router
// Implements social.coves.community.* lexicon endpoints
-
func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {
+
// allowedCommunityCreators restricts who can create communities. If empty, anyone can create.
+
func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.AtProtoAuthMiddleware, allowedCommunityCreators []string) {
// Initialize handlers
-
createHandler := community.NewCreateHandler(service)
+
createHandler := community.NewCreateHandler(service, allowedCommunityCreators)
getHandler := community.NewGetHandler(service)
updateHandler := community.NewUpdateHandler(service)
listHandler := community.NewListHandler(service)
+2 -3
internal/api/handlers/community/block.go
···
package community
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"encoding/json"
"log"
"net/http"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
)
// BlockHandler handles community blocking operations
+1 -2
internal/api/handlers/community/list.go
···
package community
import (
+
"Coves/internal/core/communities"
"encoding/json"
"net/http"
"strconv"
-
-
"Coves/internal/core/communities"
)
// ListHandler handles listing communities
+3 -4
internal/atproto/jetstream/community_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/communities"
"context"
"encoding/json"
"fmt"
···
"strings"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/communities"
-
lru "github.com/hashicorp/golang-lru/v2"
"golang.org/x/net/publicsuffix"
"golang.org/x/time/rate"
+3 -4
internal/atproto/jetstream/vote_consumer.go
···
package jetstream
import (
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/users"
+
"Coves/internal/core/votes"
"context"
"database/sql"
"fmt"
"log"
"strings"
"time"
-
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/users"
-
"Coves/internal/core/votes"
)
// VoteEventConsumer consumes vote-related events from Jetstream
+3 -4
internal/core/comments/comment_service.go
···
package comments
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
"context"
"encoding/json"
"errors"
···
"net/url"
"strings"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
)
const (
+1 -2
internal/core/communities/service.go
···
package communities
import (
+
"Coves/internal/atproto/utils"
"bytes"
"context"
"encoding/json"
···
"strings"
"sync"
"time"
-
-
"Coves/internal/atproto/utils"
)
// Community handle validation regex (DNS-valid handle: name.community.instance.com)
+2 -4
internal/db/postgres/community_repo.go
···
package postgres
import (
+
"Coves/internal/core/communities"
"context"
"database/sql"
"fmt"
"log"
"strings"
-
"Coves/internal/core/communities"
-
"github.com/lib/pq"
)
···
}
// Build sort clause - map sort enum to DB columns
-
sortColumn := "subscriber_count" // default: popular
-
sortOrder := "DESC"
+
var sortColumn, sortOrder string
switch req.Sort {
case "popular":
+7 -8
tests/e2e/error_recovery_test.go
···
package e2e
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"context"
"database/sql"
"fmt"
···
"testing"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
_ "github.com/lib/pq"
"github.com/pressly/goose/v3"
)
···
testCases := []struct {
name string
-
event jetstream.JetstreamEvent
shouldLog string
+
event jetstream.JetstreamEvent
}{
{
name: "Nil identity data",
···
if shouldFail.Load() {
t.Logf("Mock PDS: Simulating unavailability (request #%d)", requestCount.Load())
w.WriteHeader(http.StatusServiceUnavailable)
-
w.Write([]byte(`{"error":"ServiceUnavailable","message":"PDS temporarily unavailable"}`))
+
_, _ = w.Write([]byte(`{"error":"ServiceUnavailable","message":"PDS temporarily unavailable"}`))
return
}
t.Logf("Mock PDS: Serving request successfully (request #%d)", requestCount.Load())
// Simulate successful PDS response
w.WriteHeader(http.StatusOK)
-
w.Write([]byte(`{"did":"did:plc:pdstest123","handle":"pds.test"}`))
+
_, _ = w.Write([]byte(`{"did":"did:plc:pdstest123","handle":"pds.test"}`))
}))
defer mockPDS.Close()
+13 -14
tests/integration/blob_upload_e2e_test.go
···
package integration
import (
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/blobs"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"encoding/json"
···
"testing"
"time"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/blobs"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
···
if err != nil {
t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err)
}
-
defer healthResp.Body.Close()
+
defer func() { _ = healthResp.Body.Close() }()
if healthResp.StatusCode != http.StatusOK {
t.Skipf("PDS health check failed at %s: status %d", pdsURL, healthResp.StatusCode)
}
···
if err != nil {
t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start PDS.", pdsURL, err)
}
-
defer healthResp.Body.Close()
+
defer func() { _ = healthResp.Body.Close() }()
if healthResp.StatusCode != http.StatusOK {
t.Skipf("PDS health check failed at %s: status %d", pdsURL, healthResp.StatusCode)
}
···
t.Run("Accept matching image formats with correct MIME types", func(t *testing.T) {
testCases := []struct {
+
createFunc func(*testing.T, int, int, color.Color) []byte
format string
mimeType string
-
createFunc func(*testing.T, int, int, color.Color) []byte
}{
-
{"PNG", "image/png", createTestPNG},
-
{"JPEG", "image/jpeg", createTestJPEG},
+
{createTestPNG, "PNG", "image/png"},
+
{createTestJPEG, "JPEG", "image/jpeg"},
// Note: WebP requires external library (golang.org/x/image/webp)
// For now, we test that the MIME type is accepted even with PNG data
// In production, actual WebP validation would happen at PDS
-
{"WebP (MIME only)", "image/webp", createTestPNG},
+
{createTestPNG, "WebP (MIME only)", "image/webp"},
}
for _, tc := range testCases {
+14 -14
tests/integration/block_handle_resolution_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/community"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
"bytes"
"context"
"encoding/json"
···
"net/http/httptest"
"testing"
-
"Coves/internal/api/handlers/community"
-
"Coves/internal/api/middleware"
-
"Coves/internal/core/communities"
postgresRepo "Coves/internal/db/postgres"
)
···
// We expect 401 (no auth) but verify the error is NOT "Community not found"
// If handle resolution worked, we'd get past that validation
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("Handle resolution failed - got 404 CommunityNotFound")
···
// Expected: 401 Unauthorized (because we didn't add auth context)
if resp.StatusCode != http.StatusUnauthorized {
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
t.Logf("Response status: %d, body: %+v", resp.StatusCode, errorResp)
}
})
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("@-prefixed handle resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("Scoped format resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("DID resolution failed - got 404 CommunityNotFound")
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Should return 400 Bad Request for validation errors
if resp.StatusCode != http.StatusBadRequest {
···
}
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
if errorCode, ok := errorResp["error"].(string); !ok || errorCode != "InvalidRequest" {
t.Errorf("Expected error code 'InvalidRequest', got %v", errorResp["error"])
···
blockHandler.HandleBlock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Expected: 401 (auth check happens before resolution)
// In a real scenario with auth, invalid handle would return 404
···
blockHandler.HandleUnblock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Should NOT be 404 (handle resolution should work)
if resp.StatusCode == http.StatusNotFound {
···
// Expected: 401 (no auth context)
if resp.StatusCode != http.StatusUnauthorized {
var errorResp map[string]interface{}
-
json.NewDecoder(resp.Body).Decode(&errorResp)
+
_ = json.NewDecoder(resp.Body).Decode(&errorResp)
t.Logf("Response: status=%d, body=%+v", resp.StatusCode, errorResp)
}
})
···
blockHandler.HandleUnblock(w, req)
resp := w.Result()
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
// Expected: 401 (auth check happens before resolution)
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusNotFound {
+3 -4
tests/integration/comment_consumer_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/db/postgres"
)
func TestCommentConsumer_CreateComment(t *testing.T) {
+3 -4
tests/integration/comment_query_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/comments"
+
"Coves/internal/db/postgres"
"context"
"database/sql"
"encoding/json"
···
"testing"
"time"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/comments"
-
"Coves/internal/db/postgres"
-
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+4 -5
tests/integration/comment_vote_test.go
···
package integration
import (
-
"context"
-
"fmt"
-
"testing"
-
"time"
-
"Coves/internal/atproto/jetstream"
"Coves/internal/core/comments"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"testing"
+
"time"
)
// TestCommentVote_CreateAndUpdate tests voting on comments and vote count updates
+13 -14
tests/integration/community_e2e_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/api/routes"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/atproto/utils"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/api/middleware"
-
"Coves/internal/api/routes"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/atproto/utils"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
-
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
_ "github.com/lib/pq"
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
···
}
var listResp struct {
-
Communities []communities.Community `json:"communities"`
Cursor string `json:"cursor"`
+
Communities []communities.Community `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
+4 -5
tests/integration/community_hostedby_security_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"net/http"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/db/postgres"
)
// TestHostedByVerification_DomainMatching tests that hostedBy domain must match handle domain
···
// Return a DID document with matching alsoKnownAs
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
-
fmt.Fprintf(w, `{
+
_, _ = fmt.Fprintf(w, `{
"id": "did:web:example.com",
"alsoKnownAs": ["at://example.com"],
"verificationMethod": [],
···
// Return a DID document WITHOUT alsoKnownAs field
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
-
fmt.Fprintf(w, `{
+
_, _ = fmt.Fprintf(w, `{
"id": "did:web:example.com",
"verificationMethod": [],
"service": []
+2 -3
tests/integration/community_repo_test.go
···
package integration
import (
+
"Coves/internal/core/communities"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/core/communities"
-
"Coves/internal/db/postgres"
)
func TestCommunityRepository_Create(t *testing.T) {
+6 -7
tests/integration/concurrent_scenarios_test.go
···
package integration
import (
-
"context"
-
"fmt"
-
"sync"
-
"testing"
-
"time"
-
"Coves/internal/atproto/jetstream"
"Coves/internal/core/comments"
"Coves/internal/core/communities"
"Coves/internal/core/users"
"Coves/internal/db/postgres"
+
"context"
+
"fmt"
+
"sync"
+
"testing"
+
"time"
)
// TestConcurrentVoting_MultipleUsersOnSamePost tests race conditions when multiple users
···
wg.Add(numAttempts)
type result struct {
-
success bool
err error
+
success bool
}
results := make(chan result, numAttempts)
+3 -4
tests/integration/jwt_verification_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/atproto/auth"
"fmt"
"net/http"
"net/http/httptest"
···
"strings"
"testing"
"time"
-
-
"Coves/internal/api/middleware"
-
"Coves/internal/atproto/auth"
)
// TestJWTSignatureVerification tests end-to-end JWT signature verification
···
// Check if JWKS is available (production PDS) or symmetric secret (dev PDS)
jwksResp, _ := http.Get(pdsURL + "/oauth/jwks")
if jwksResp != nil {
-
defer jwksResp.Body.Close()
+
defer func() { _ = jwksResp.Body.Close() }()
}
t.Run("JWT parsing and middleware integration", func(t *testing.T) {
+3 -4
tests/integration/post_consumer_test.go
···
package integration
import (
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
-
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
)
// TestPostConsumer_CommentCountReconciliation tests that post comment_count
+3 -4
tests/integration/timeline_test.go
···
package integration
import (
+
"Coves/internal/api/handlers/timeline"
+
"Coves/internal/api/middleware"
+
"Coves/internal/db/postgres"
"context"
"encoding/json"
"fmt"
···
"testing"
"time"
-
"Coves/internal/api/handlers/timeline"
-
"Coves/internal/api/middleware"
-
"Coves/internal/db/postgres"
-
timelineCore "Coves/internal/core/timeline"
"github.com/stretchr/testify/assert"
+11 -11
tests/integration/user_journey_e2e_test.go
···
package integration
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/api/routes"
+
"Coves/internal/atproto/identity"
+
"Coves/internal/atproto/jetstream"
+
"Coves/internal/core/communities"
+
"Coves/internal/core/posts"
+
"Coves/internal/core/users"
+
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"testing"
"time"
-
"Coves/internal/api/middleware"
-
"Coves/internal/api/routes"
-
"Coves/internal/atproto/identity"
-
"Coves/internal/atproto/jetstream"
-
"Coves/internal/core/communities"
-
"Coves/internal/core/posts"
timelineCore "Coves/internal/core/timeline"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Community creation should succeed")
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Post creation should succeed")
···
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
-
defer resp.Body.Close()
+
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "Subscription should succeed")
+23
static/.well-known/did.json
···
+
{
+
"@context": [
+
"https://www.w3.org/ns/did/v1",
+
"https://w3id.org/security/multikey/v1"
+
],
+
"id": "did:web:coves.social",
+
"alsoKnownAs": ["at://coves.social"],
+
"verificationMethod": [
+
{
+
"id": "did:web:coves.social#atproto",
+
"type": "Multikey",
+
"controller": "did:web:coves.social",
+
"publicKeyMultibase": "zQ3shu1T3Y3MYoC1n7fCqkZqyrk8FiY3PV3BYM2JwyqcXFY6s"
+
}
+
],
+
"service": [
+
{
+
"id": "#atproto_pds",
+
"type": "AtprotoPersonalDataServer",
+
"serviceEndpoint": "https://pds.coves.me"
+
}
+
]
+
}
+1 -1
docs/E2E_TESTING.md
···
Query via API:
```bash
-
curl "http://localhost:8081/xrpc/social.coves.actor.getProfile?actor=alice.local.coves.dev"
+
curl "http://localhost:8081/xrpc/social.coves.actor.getprofile?actor=alice.local.coves.dev"
```
Expected response:
+3 -3
internal/api/routes/user.go
···
func RegisterUserRoutes(r chi.Router, service users.UserService) {
h := NewUserHandler(service)
-
// social.coves.actor.getProfile - query endpoint
-
r.Get("/xrpc/social.coves.actor.getProfile", h.GetProfile)
+
// social.coves.actor.getprofile - query endpoint
+
r.Get("/xrpc/social.coves.actor.getprofile", h.GetProfile)
// social.coves.actor.signup - procedure endpoint
r.Post("/xrpc/social.coves.actor.signup", h.Signup)
}
-
// GetProfile handles social.coves.actor.getProfile
+
// GetProfile handles social.coves.actor.getprofile
// Query endpoint that retrieves a user profile by DID or handle
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+1 -1
internal/atproto/lexicon/social/coves/actor/getProfile.json
···
{
"lexicon": 1,
-
"id": "social.coves.actor.getProfile",
+
"id": "social.coves.actor.getprofile",
"defs": {
"main": {
"type": "query",
+1 -1
internal/atproto/lexicon/social/coves/actor/updateProfile.json
···
{
"lexicon": 1,
-
"id": "social.coves.actor.updateProfile",
+
"id": "social.coves.actor.updateprofile",
"defs": {
"main": {
"type": "procedure",
+4 -4
tests/integration/user_test.go
···
// Test 1: Get profile by DID
t.Run("Get Profile By DID", func(t *testing.T) {
-
req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=did:plc:endpoint123", nil)
+
req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=did:plc:endpoint123", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
···
// Test 2: Get profile by handle
t.Run("Get Profile By Handle", func(t *testing.T) {
-
req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=bob.test", nil)
+
req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=bob.test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
···
// Test 3: Missing actor parameter
t.Run("Missing Actor Parameter", func(t *testing.T) {
-
req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile", nil)
+
req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
···
// Test 4: User not found
t.Run("User Not Found", func(t *testing.T) {
-
req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getProfile?actor=nonexistent.test", nil)
+
req := httptest.NewRequest("GET", "/xrpc/social.coves.actor.getprofile?actor=nonexistent.test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
+44 -5
internal/atproto/lexicon/social/coves/embed/external.json
···
"defs": {
"main": {
"type": "object",
-
"description": "External link embed with preview metadata and provider support",
+
"description": "External link embed with optional aggregated sources for megathreads",
"required": ["external"],
"properties": {
"external": {
···
},
"external": {
"type": "object",
-
"description": "External link metadata",
+
"description": "Primary external link metadata",
"required": ["uri"],
"properties": {
"uri": {
"type": "string",
"format": "uri",
-
"description": "URI of the external content"
+
"description": "URI of the primary external content"
},
"title": {
"type": "string",
···
"type": "blob",
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 1000000,
-
"description": "Thumbnail image for the link"
+
"description": "Thumbnail image for the post (applies to primary link)"
},
"domain": {
"type": "string",
-
"description": "Domain of the linked content"
+
"maxLength": 253,
+
"description": "Domain of the linked content (e.g., nytimes.com)"
},
"embedType": {
"type": "string",
···
},
"provider": {
"type": "string",
+
"maxLength": 100,
"description": "Service provider name (e.g., imgur, streamable)"
},
"images": {
···
"type": "integer",
"minimum": 0,
"description": "Total number of items if more than displayed (for galleries)"
+
},
+
"sources": {
+
"type": "array",
+
"description": "Aggregated source links for megathreads. Each source references an original article and optionally the Coves post that shared it",
+
"maxLength": 50,
+
"items": {
+
"type": "ref",
+
"ref": "#source"
+
}
+
}
+
}
+
},
+
"source": {
+
"type": "object",
+
"description": "A source link aggregated into a megathread",
+
"required": ["uri"],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "uri",
+
"description": "URI of the source article"
+
},
+
"title": {
+
"type": "string",
+
"maxLength": 500,
+
"maxGraphemes": 500,
+
"description": "Title of the source article"
+
},
+
"domain": {
+
"type": "string",
+
"maxLength": 253,
+
"description": "Domain of the source (e.g., nytimes.com)"
+
},
+
"sourcePost": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Reference to the Coves post that originally shared this link. Used for feed deprioritization of rolled-up posts"
}
}
}
+52
internal/atproto/auth/combined_key_fetcher.go
···
+
package auth
+
+
import (
+
"context"
+
"fmt"
+
"strings"
+
+
indigoIdentity "github.com/bluesky-social/indigo/atproto/identity"
+
)
+
+
// CombinedKeyFetcher handles JWT public key fetching for both:
+
// - DID issuers (did:plc:, did:web:) โ†’ resolves via DID document
+
// - URL issuers (https://) โ†’ fetches via JWKS endpoint (legacy/fallback)
+
//
+
// For atproto service authentication, the issuer is typically the user's DID,
+
// and the signing key is published in their DID document.
+
type CombinedKeyFetcher struct {
+
didFetcher *DIDKeyFetcher
+
jwksFetcher JWKSFetcher
+
}
+
+
// NewCombinedKeyFetcher creates a key fetcher that supports both DID and URL issuers.
+
// Parameters:
+
// - directory: Indigo's identity directory for DID resolution
+
// - jwksFetcher: fallback JWKS fetcher for URL issuers (can be nil if not needed)
+
func NewCombinedKeyFetcher(directory indigoIdentity.Directory, jwksFetcher JWKSFetcher) *CombinedKeyFetcher {
+
return &CombinedKeyFetcher{
+
didFetcher: NewDIDKeyFetcher(directory),
+
jwksFetcher: jwksFetcher,
+
}
+
}
+
+
// FetchPublicKey fetches the public key for verifying a JWT.
+
// Routes to the appropriate fetcher based on issuer format:
+
// - DID (did:plc:, did:web:) โ†’ DIDKeyFetcher
+
// - URL (https://) โ†’ JWKSFetcher
+
func (f *CombinedKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+
// Check if issuer is a DID
+
if strings.HasPrefix(issuer, "did:") {
+
return f.didFetcher.FetchPublicKey(ctx, issuer, token)
+
}
+
+
// Check if issuer is a URL (https:// or http:// in dev)
+
if strings.HasPrefix(issuer, "https://") || strings.HasPrefix(issuer, "http://") {
+
if f.jwksFetcher == nil {
+
return nil, fmt.Errorf("URL issuer %s requires JWKS fetcher, but none configured", issuer)
+
}
+
return f.jwksFetcher.FetchPublicKey(ctx, issuer, token)
+
}
+
+
return nil, fmt.Errorf("unsupported issuer format: %s (expected DID or URL)", issuer)
+
}
+116
internal/atproto/auth/did_key_fetcher.go
···
+
package auth
+
+
import (
+
"context"
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"encoding/base64"
+
"fmt"
+
"math/big"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
indigoIdentity "github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
// DIDKeyFetcher fetches public keys from DID documents for JWT verification.
+
// This is the primary method for atproto service authentication, where:
+
// - The JWT issuer is the user's DID (e.g., did:plc:abc123)
+
// - The signing key is published in the user's DID document
+
// - Verification happens by resolving the DID and checking the signature
+
type DIDKeyFetcher struct {
+
directory indigoIdentity.Directory
+
}
+
+
// NewDIDKeyFetcher creates a new DID-based key fetcher.
+
func NewDIDKeyFetcher(directory indigoIdentity.Directory) *DIDKeyFetcher {
+
return &DIDKeyFetcher{
+
directory: directory,
+
}
+
}
+
+
// FetchPublicKey fetches the public key for verifying a JWT from the issuer's DID document.
+
// For DID issuers (did:plc: or did:web:), resolves the DID and extracts the signing key.
+
// Returns an *ecdsa.PublicKey suitable for use with jwt-go.
+
func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+
// Only handle DID issuers
+
if !strings.HasPrefix(issuer, "did:") {
+
return nil, fmt.Errorf("DIDKeyFetcher only handles DID issuers, got: %s", issuer)
+
}
+
+
// Parse the DID
+
did, err := syntax.ParseDID(issuer)
+
if err != nil {
+
return nil, fmt.Errorf("invalid DID format: %w", err)
+
}
+
+
// Resolve the DID to get the identity (includes public keys)
+
ident, err := f.directory.LookupDID(ctx, did)
+
if err != nil {
+
return nil, fmt.Errorf("failed to resolve DID %s: %w", issuer, err)
+
}
+
+
// Get the atproto signing key from the DID document
+
pubKey, err := ident.PublicKey()
+
if err != nil {
+
return nil, fmt.Errorf("failed to get public key from DID document: %w", err)
+
}
+
+
// Convert to JWK format to extract coordinates
+
jwk, err := pubKey.JWK()
+
if err != nil {
+
return nil, fmt.Errorf("failed to convert public key to JWK: %w", err)
+
}
+
+
// Convert atcrypto JWK to Go ecdsa.PublicKey
+
return atcryptoJWKToECDSA(jwk)
+
}
+
+
// atcryptoJWKToECDSA converts an atcrypto.JWK to a Go ecdsa.PublicKey
+
func atcryptoJWKToECDSA(jwk *atcrypto.JWK) (*ecdsa.PublicKey, error) {
+
if jwk.KeyType != "EC" {
+
return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType)
+
}
+
+
// Decode X and Y coordinates (base64url, no padding)
+
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK X coordinate encoding: %w", err)
+
}
+
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK Y coordinate encoding: %w", err)
+
}
+
+
var ecCurve elliptic.Curve
+
switch jwk.Curve {
+
case "P-256":
+
ecCurve = elliptic.P256()
+
case "P-384":
+
ecCurve = elliptic.P384()
+
case "P-521":
+
ecCurve = elliptic.P521()
+
case "secp256k1":
+
// secp256k1 (K-256) is used by some atproto implementations
+
// Go's standard library doesn't include secp256k1, but we can still
+
// construct the key - jwt-go may not support it directly
+
return nil, fmt.Errorf("secp256k1 curve requires special handling for JWT verification")
+
default:
+
return nil, fmt.Errorf("unsupported JWK curve: %s", jwk.Curve)
+
}
+
+
// Create the public key
+
pubKey := &ecdsa.PublicKey{
+
Curve: ecCurve,
+
X: new(big.Int).SetBytes(xBytes),
+
Y: new(big.Int).SetBytes(yBytes),
+
}
+
+
// Validate point is on curve
+
if !ecCurve.IsOnCurve(pubKey.X, pubKey.Y) {
+
return nil, fmt.Errorf("invalid public key: point not on curve")
+
}
+
+
return pubKey, nil
+
}
+5
.env.dev
···
# When false, verifies JWT signature against issuer's JWKS
AUTH_SKIP_VERIFY=true
+
# HS256 Issuers: PDSes allowed to use HS256 (shared secret) authentication
+
# Must share PDS_JWT_SECRET with Coves instance. External PDSes use ES256 via DID resolution.
+
# For local dev, allow the local PDS or turn AUTH_SKIP_VERIFY = true
+
HS256_ISSUERS=http://localhost:3001
+
# Logging
LOG_LEVEL=debug
LOG_ENABLED=true
+484
internal/atproto/auth/dpop.go
···
+
package auth
+
+
import (
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"math/big"
+
"strings"
+
"sync"
+
"time"
+
+
indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/golang-jwt/jwt/v5"
+
)
+
+
// NonceCache provides replay protection for DPoP proofs by tracking seen jti values.
+
// This prevents an attacker from reusing a captured DPoP proof within the validity window.
+
// Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks.
+
type NonceCache struct {
+
seen map[string]time.Time // jti -> expiration time
+
stopCh chan struct{}
+
maxAge time.Duration // How long to keep entries
+
cleanup time.Duration // How often to clean up expired entries
+
mu sync.RWMutex
+
}
+
+
// NewNonceCache creates a new nonce cache for DPoP replay protection.
+
// maxAge should match or exceed DPoPVerifier.MaxProofAge.
+
func NewNonceCache(maxAge time.Duration) *NonceCache {
+
nc := &NonceCache{
+
seen: make(map[string]time.Time),
+
maxAge: maxAge,
+
cleanup: maxAge / 2, // Clean up at half the max age
+
stopCh: make(chan struct{}),
+
}
+
+
// Start background cleanup goroutine
+
go nc.cleanupLoop()
+
+
return nc
+
}
+
+
// CheckAndStore checks if a jti has been seen before and stores it if not.
+
// Returns true if the jti is fresh (not a replay), false if it's a replay.
+
func (nc *NonceCache) CheckAndStore(jti string) bool {
+
nc.mu.Lock()
+
defer nc.mu.Unlock()
+
+
now := time.Now()
+
expiry := now.Add(nc.maxAge)
+
+
// Check if already seen
+
if existingExpiry, seen := nc.seen[jti]; seen {
+
// Still valid (not expired) - this is a replay
+
if existingExpiry.After(now) {
+
return false
+
}
+
// Expired entry - allow reuse and update expiry
+
}
+
+
// Store the new jti
+
nc.seen[jti] = expiry
+
return true
+
}
+
+
// cleanupLoop periodically removes expired entries from the cache
+
func (nc *NonceCache) cleanupLoop() {
+
ticker := time.NewTicker(nc.cleanup)
+
defer ticker.Stop()
+
+
for {
+
select {
+
case <-ticker.C:
+
nc.cleanupExpired()
+
case <-nc.stopCh:
+
return
+
}
+
}
+
}
+
+
// cleanupExpired removes expired entries from the cache
+
func (nc *NonceCache) cleanupExpired() {
+
nc.mu.Lock()
+
defer nc.mu.Unlock()
+
+
now := time.Now()
+
for jti, expiry := range nc.seen {
+
if expiry.Before(now) {
+
delete(nc.seen, jti)
+
}
+
}
+
}
+
+
// Stop stops the cleanup goroutine. Call this when done with the cache.
+
func (nc *NonceCache) Stop() {
+
close(nc.stopCh)
+
}
+
+
// Size returns the number of entries in the cache (for testing/monitoring)
+
func (nc *NonceCache) Size() int {
+
nc.mu.RLock()
+
defer nc.mu.RUnlock()
+
return len(nc.seen)
+
}
+
+
// DPoPClaims represents the claims in a DPoP proof JWT (RFC 9449)
+
type DPoPClaims struct {
+
jwt.RegisteredClaims
+
+
// HTTP method of the request (e.g., "GET", "POST")
+
HTTPMethod string `json:"htm"`
+
+
// HTTP URI of the request (without query and fragment parts)
+
HTTPURI string `json:"htu"`
+
+
// Access token hash (optional, for token binding)
+
AccessTokenHash string `json:"ath,omitempty"`
+
}
+
+
// DPoPProof represents a parsed and verified DPoP proof
+
type DPoPProof struct {
+
RawPublicJWK map[string]interface{}
+
Claims *DPoPClaims
+
PublicKey interface{} // *ecdsa.PublicKey or similar
+
Thumbprint string // JWK thumbprint (base64url)
+
}
+
+
// DPoPVerifier verifies DPoP proofs for OAuth token binding
+
type DPoPVerifier struct {
+
// Optional: custom nonce validation function (for server-issued nonces)
+
ValidateNonce func(nonce string) bool
+
+
// NonceCache for replay protection (optional but recommended)
+
// If nil, jti replay protection is disabled
+
NonceCache *NonceCache
+
+
// Maximum allowed clock skew for timestamp validation
+
MaxClockSkew time.Duration
+
+
// Maximum age of DPoP proof (prevents replay with old proofs)
+
MaxProofAge time.Duration
+
}
+
+
// NewDPoPVerifier creates a DPoP verifier with sensible defaults including replay protection
+
func NewDPoPVerifier() *DPoPVerifier {
+
maxProofAge := 5 * time.Minute
+
return &DPoPVerifier{
+
MaxClockSkew: 30 * time.Second,
+
MaxProofAge: maxProofAge,
+
NonceCache: NewNonceCache(maxProofAge),
+
}
+
}
+
+
// NewDPoPVerifierWithoutReplayProtection creates a DPoP verifier without replay protection.
+
// This should only be used in testing or when replay protection is handled externally.
+
func NewDPoPVerifierWithoutReplayProtection() *DPoPVerifier {
+
return &DPoPVerifier{
+
MaxClockSkew: 30 * time.Second,
+
MaxProofAge: 5 * time.Minute,
+
NonceCache: nil, // No replay protection
+
}
+
}
+
+
// Stop stops background goroutines. Call this when shutting down.
+
func (v *DPoPVerifier) Stop() {
+
if v.NonceCache != nil {
+
v.NonceCache.Stop()
+
}
+
}
+
+
// VerifyDPoPProof verifies a DPoP proof JWT and returns the parsed proof
+
func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, error) {
+
// Parse the DPoP JWT without verification first to extract the header
+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
+
token, _, err := parser.ParseUnverified(dpopProof, &DPoPClaims{})
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse DPoP proof: %w", err)
+
}
+
+
// Extract and validate the header
+
header, ok := token.Header["typ"].(string)
+
if !ok || header != "dpop+jwt" {
+
return nil, fmt.Errorf("invalid DPoP proof: typ must be 'dpop+jwt', got '%s'", header)
+
}
+
+
alg, ok := token.Header["alg"].(string)
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: missing alg header")
+
}
+
+
// Extract the JWK from the header
+
jwkRaw, ok := token.Header["jwk"]
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: missing jwk header")
+
}
+
+
jwkMap, ok := jwkRaw.(map[string]interface{})
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP proof: jwk must be an object")
+
}
+
+
// Parse the public key from JWK
+
publicKey, err := parseJWKToPublicKey(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("invalid DPoP proof JWK: %w", err)
+
}
+
+
// Calculate the JWK thumbprint
+
thumbprint, err := CalculateJWKThumbprint(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
+
}
+
+
// Now verify the signature
+
verifiedToken, err := jwt.ParseWithClaims(dpopProof, &DPoPClaims{}, func(token *jwt.Token) (interface{}, error) {
+
// Verify the signing method matches what we expect
+
switch alg {
+
case "ES256":
+
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+
}
+
case "ES384", "ES512":
+
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+
}
+
case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512":
+
// RSA methods - we primarily support ES256 for atproto
+
return nil, fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg)
+
default:
+
return nil, fmt.Errorf("unsupported DPoP algorithm: %s", alg)
+
}
+
return publicKey, nil
+
})
+
if err != nil {
+
return nil, fmt.Errorf("DPoP proof signature verification failed: %w", err)
+
}
+
+
claims, ok := verifiedToken.Claims.(*DPoPClaims)
+
if !ok {
+
return nil, fmt.Errorf("invalid DPoP claims type")
+
}
+
+
// Validate the claims
+
if err := v.validateDPoPClaims(claims, httpMethod, httpURI); err != nil {
+
return nil, err
+
}
+
+
return &DPoPProof{
+
Claims: claims,
+
PublicKey: publicKey,
+
Thumbprint: thumbprint,
+
RawPublicJWK: jwkMap,
+
}, nil
+
}
+
+
// validateDPoPClaims validates the DPoP proof claims
+
func (v *DPoPVerifier) validateDPoPClaims(claims *DPoPClaims, expectedMethod, expectedURI string) error {
+
// Validate jti (unique identifier) is present
+
if claims.ID == "" {
+
return fmt.Errorf("DPoP proof missing jti claim")
+
}
+
+
// Validate htm (HTTP method)
+
if !strings.EqualFold(claims.HTTPMethod, expectedMethod) {
+
return fmt.Errorf("DPoP proof htm mismatch: expected %s, got %s", expectedMethod, claims.HTTPMethod)
+
}
+
+
// Validate htu (HTTP URI) - compare without query/fragment
+
expectedURIBase := stripQueryFragment(expectedURI)
+
claimURIBase := stripQueryFragment(claims.HTTPURI)
+
if expectedURIBase != claimURIBase {
+
return fmt.Errorf("DPoP proof htu mismatch: expected %s, got %s", expectedURIBase, claimURIBase)
+
}
+
+
// Validate iat (issued at) is present and recent
+
if claims.IssuedAt == nil {
+
return fmt.Errorf("DPoP proof missing iat claim")
+
}
+
+
now := time.Now()
+
iat := claims.IssuedAt.Time
+
+
// Check clock skew (not too far in the future)
+
if iat.After(now.Add(v.MaxClockSkew)) {
+
return fmt.Errorf("DPoP proof iat is in the future")
+
}
+
+
// Check proof age (not too old)
+
if now.Sub(iat) > v.MaxProofAge {
+
return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge)
+
}
+
+
// SECURITY: Check for replay attack using jti
+
// Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks
+
if v.NonceCache != nil {
+
if !v.NonceCache.CheckAndStore(claims.ID) {
+
return fmt.Errorf("DPoP proof replay detected: jti %s already used", claims.ID)
+
}
+
}
+
+
return nil
+
}
+
+
// VerifyTokenBinding verifies that the DPoP proof binds to the access token
+
// by comparing the proof's thumbprint to the token's cnf.jkt claim
+
func (v *DPoPVerifier) VerifyTokenBinding(proof *DPoPProof, expectedThumbprint string) error {
+
if proof.Thumbprint != expectedThumbprint {
+
return fmt.Errorf("DPoP proof thumbprint mismatch: token expects %s, proof has %s",
+
expectedThumbprint, proof.Thumbprint)
+
}
+
return nil
+
}
+
+
// CalculateJWKThumbprint calculates the JWK thumbprint per RFC 7638
+
// The thumbprint is the base64url-encoded SHA-256 hash of the canonical JWK representation
+
func CalculateJWKThumbprint(jwk map[string]interface{}) (string, error) {
+
kty, ok := jwk["kty"].(string)
+
if !ok {
+
return "", fmt.Errorf("JWK missing kty")
+
}
+
+
// Build the canonical JWK representation based on key type
+
// Per RFC 7638, only specific members are included, in lexicographic order
+
var canonical map[string]string
+
+
switch kty {
+
case "EC":
+
crv, ok := jwk["crv"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing crv")
+
}
+
x, ok := jwk["x"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing x")
+
}
+
y, ok := jwk["y"].(string)
+
if !ok {
+
return "", fmt.Errorf("EC JWK missing y")
+
}
+
// Lexicographic order: crv, kty, x, y
+
canonical = map[string]string{
+
"crv": crv,
+
"kty": kty,
+
"x": x,
+
"y": y,
+
}
+
case "RSA":
+
e, ok := jwk["e"].(string)
+
if !ok {
+
return "", fmt.Errorf("RSA JWK missing e")
+
}
+
n, ok := jwk["n"].(string)
+
if !ok {
+
return "", fmt.Errorf("RSA JWK missing n")
+
}
+
// Lexicographic order: e, kty, n
+
canonical = map[string]string{
+
"e": e,
+
"kty": kty,
+
"n": n,
+
}
+
case "OKP":
+
crv, ok := jwk["crv"].(string)
+
if !ok {
+
return "", fmt.Errorf("OKP JWK missing crv")
+
}
+
x, ok := jwk["x"].(string)
+
if !ok {
+
return "", fmt.Errorf("OKP JWK missing x")
+
}
+
// Lexicographic order: crv, kty, x
+
canonical = map[string]string{
+
"crv": crv,
+
"kty": kty,
+
"x": x,
+
}
+
default:
+
return "", fmt.Errorf("unsupported JWK key type: %s", kty)
+
}
+
+
// Serialize to JSON (Go's json.Marshal produces lexicographically ordered keys for map[string]string)
+
canonicalJSON, err := json.Marshal(canonical)
+
if err != nil {
+
return "", fmt.Errorf("failed to serialize canonical JWK: %w", err)
+
}
+
+
// SHA-256 hash
+
hash := sha256.Sum256(canonicalJSON)
+
+
// Base64url encode (no padding)
+
thumbprint := base64.RawURLEncoding.EncodeToString(hash[:])
+
+
return thumbprint, nil
+
}
+
+
// parseJWKToPublicKey parses a JWK map to a Go public key
+
func parseJWKToPublicKey(jwkMap map[string]interface{}) (interface{}, error) {
+
// Convert map to JSON bytes for indigo's parser
+
jwkBytes, err := json.Marshal(jwkMap)
+
if err != nil {
+
return nil, fmt.Errorf("failed to serialize JWK: %w", err)
+
}
+
+
// Try to parse with indigo's crypto package
+
pubKey, err := indigoCrypto.ParsePublicJWKBytes(jwkBytes)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse JWK: %w", err)
+
}
+
+
// Convert indigo's PublicKey to Go's ecdsa.PublicKey
+
jwk, err := pubKey.JWK()
+
if err != nil {
+
return nil, fmt.Errorf("failed to get JWK from public key: %w", err)
+
}
+
+
// Use our existing conversion function
+
return atcryptoJWKToECDSAFromIndigoJWK(jwk)
+
}
+
+
// atcryptoJWKToECDSAFromIndigoJWK converts an indigo JWK to Go ecdsa.PublicKey
+
func atcryptoJWKToECDSAFromIndigoJWK(jwk *indigoCrypto.JWK) (*ecdsa.PublicKey, error) {
+
if jwk.KeyType != "EC" {
+
return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType)
+
}
+
+
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK X coordinate: %w", err)
+
}
+
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
+
if err != nil {
+
return nil, fmt.Errorf("invalid JWK Y coordinate: %w", err)
+
}
+
+
var curve ecdsa.PublicKey
+
switch jwk.Curve {
+
case "P-256":
+
curve.Curve = ecdsaP256Curve()
+
case "P-384":
+
curve.Curve = ecdsaP384Curve()
+
case "P-521":
+
curve.Curve = ecdsaP521Curve()
+
default:
+
return nil, fmt.Errorf("unsupported curve: %s", jwk.Curve)
+
}
+
+
curve.X = new(big.Int).SetBytes(xBytes)
+
curve.Y = new(big.Int).SetBytes(yBytes)
+
+
return &curve, nil
+
}
+
+
// Helper functions for elliptic curves
+
func ecdsaP256Curve() elliptic.Curve { return elliptic.P256() }
+
func ecdsaP384Curve() elliptic.Curve { return elliptic.P384() }
+
func ecdsaP521Curve() elliptic.Curve { return elliptic.P521() }
+
+
// stripQueryFragment removes query and fragment from a URI
+
func stripQueryFragment(uri string) string {
+
if idx := strings.Index(uri, "?"); idx != -1 {
+
uri = uri[:idx]
+
}
+
if idx := strings.Index(uri, "#"); idx != -1 {
+
uri = uri[:idx]
+
}
+
return uri
+
}
+
+
// ExtractCnfJkt extracts the cnf.jkt (confirmation key thumbprint) from JWT claims
+
func ExtractCnfJkt(claims *Claims) (string, error) {
+
if claims.Confirmation == nil {
+
return "", fmt.Errorf("token missing cnf claim (no DPoP binding)")
+
}
+
+
jkt, ok := claims.Confirmation["jkt"].(string)
+
if !ok || jkt == "" {
+
return "", fmt.Errorf("token cnf claim missing jkt (DPoP key thumbprint)")
+
}
+
+
return jkt, nil
+
}
+921
internal/atproto/auth/dpop_test.go
···
+
package auth
+
+
import (
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/rand"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"strings"
+
"testing"
+
"time"
+
+
"github.com/golang-jwt/jwt/v5"
+
"github.com/google/uuid"
+
)
+
+
// === Test Helpers ===
+
+
// testECKey holds a test ES256 key pair
+
type testECKey struct {
+
privateKey *ecdsa.PrivateKey
+
publicKey *ecdsa.PublicKey
+
jwk map[string]interface{}
+
thumbprint string
+
}
+
+
// generateTestES256Key generates a test ES256 key pair and JWK
+
func generateTestES256Key(t *testing.T) *testECKey {
+
t.Helper()
+
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("Failed to generate test key: %v", err)
+
}
+
+
// Encode public key coordinates as base64url
+
xBytes := privateKey.PublicKey.X.Bytes()
+
yBytes := privateKey.PublicKey.Y.Bytes()
+
+
// P-256 coordinates must be 32 bytes (pad if needed)
+
xBytes = padTo32Bytes(xBytes)
+
yBytes = padTo32Bytes(yBytes)
+
+
x := base64.RawURLEncoding.EncodeToString(xBytes)
+
y := base64.RawURLEncoding.EncodeToString(yBytes)
+
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": x,
+
"y": y,
+
}
+
+
// Calculate thumbprint
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("Failed to calculate thumbprint: %v", err)
+
}
+
+
return &testECKey{
+
privateKey: privateKey,
+
publicKey: &privateKey.PublicKey,
+
jwk: jwk,
+
thumbprint: thumbprint,
+
}
+
}
+
+
// padTo32Bytes pads a byte slice to 32 bytes (required for P-256 coordinates)
+
func padTo32Bytes(b []byte) []byte {
+
if len(b) >= 32 {
+
return b
+
}
+
padded := make([]byte, 32)
+
copy(padded[32-len(b):], b)
+
return padded
+
}
+
+
// createDPoPProof creates a DPoP proof JWT for testing
+
func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string {
+
t.Helper()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
tokenString, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create DPoP proof: %v", err)
+
}
+
+
return tokenString
+
}
+
+
// === JWK Thumbprint Tests (RFC 7638) ===
+
+
func TestCalculateJWKThumbprint_EC_P256(t *testing.T) {
+
// Test with known values from RFC 7638 Appendix A (adapted for P-256)
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzGl96oc0CWuis",
+
"y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Hv5J0fSKbE",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint")
+
}
+
+
// Verify it's valid base64url
+
_, err = base64.RawURLEncoding.DecodeString(thumbprint)
+
if err != nil {
+
t.Errorf("Thumbprint is not valid base64url: %v", err)
+
}
+
+
// Verify length (SHA-256 produces 32 bytes = 43 base64url chars)
+
if len(thumbprint) != 43 {
+
t.Errorf("Expected thumbprint length 43, got %d", len(thumbprint))
+
}
+
}
+
+
func TestCalculateJWKThumbprint_Deterministic(t *testing.T) {
+
// Same key should produce same thumbprint
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "test-x-coordinate",
+
"y": "test-y-coordinate",
+
}
+
+
thumbprint1, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("First CalculateJWKThumbprint failed: %v", err)
+
}
+
+
thumbprint2, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("Second CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint1 != thumbprint2 {
+
t.Errorf("Thumbprints are not deterministic: %s != %s", thumbprint1, thumbprint2)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_DifferentKeys(t *testing.T) {
+
// Different keys should produce different thumbprints
+
jwk1 := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "coordinate-x-1",
+
"y": "coordinate-y-1",
+
}
+
+
jwk2 := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "coordinate-x-2",
+
"y": "coordinate-y-2",
+
}
+
+
thumbprint1, err := CalculateJWKThumbprint(jwk1)
+
if err != nil {
+
t.Fatalf("First CalculateJWKThumbprint failed: %v", err)
+
}
+
+
thumbprint2, err := CalculateJWKThumbprint(jwk2)
+
if err != nil {
+
t.Fatalf("Second CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if thumbprint1 == thumbprint2 {
+
t.Error("Different keys produced same thumbprint (collision)")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_MissingKty(t *testing.T) {
+
jwk := map[string]interface{}{
+
"crv": "P-256",
+
"x": "test-x",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing kty, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing kty") {
+
t.Errorf("Expected error about missing kty, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingCrv(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"x": "test-x",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing crv, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing crv") {
+
t.Errorf("Expected error about missing crv, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingX(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"y": "test-y",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing x, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing x") {
+
t.Errorf("Expected error about missing x, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_EC_MissingY(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "test-x",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for missing y, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing y") {
+
t.Errorf("Expected error about missing y, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_RSA(t *testing.T) {
+
// Test RSA key thumbprint calculation
+
jwk := map[string]interface{}{
+
"kty": "RSA",
+
"e": "AQAB",
+
"n": "test-modulus",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed for RSA: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint for RSA key")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_OKP(t *testing.T) {
+
// Test OKP (Octet Key Pair) thumbprint calculation
+
jwk := map[string]interface{}{
+
"kty": "OKP",
+
"crv": "Ed25519",
+
"x": "test-x-coordinate",
+
}
+
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed for OKP: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint for OKP key")
+
}
+
}
+
+
func TestCalculateJWKThumbprint_UnsupportedKeyType(t *testing.T) {
+
jwk := map[string]interface{}{
+
"kty": "UNKNOWN",
+
}
+
+
_, err := CalculateJWKThumbprint(jwk)
+
if err == nil {
+
t.Error("Expected error for unsupported key type, got nil")
+
}
+
if err != nil && !contains(err.Error(), "unsupported JWK key type") {
+
t.Errorf("Expected error about unsupported key type, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_CanonicalJSON(t *testing.T) {
+
// RFC 7638 requires lexicographic ordering of keys in canonical JSON
+
// This test verifies that the canonical JSON is correctly ordered
+
+
jwk := map[string]interface{}{
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "x-coord",
+
"y": "y-coord",
+
}
+
+
// The canonical JSON should be: {"crv":"P-256","kty":"EC","x":"x-coord","y":"y-coord"}
+
// (lexicographically ordered: crv, kty, x, y)
+
+
canonical := map[string]string{
+
"crv": "P-256",
+
"kty": "EC",
+
"x": "x-coord",
+
"y": "y-coord",
+
}
+
+
canonicalJSON, err := json.Marshal(canonical)
+
if err != nil {
+
t.Fatalf("Failed to marshal canonical JSON: %v", err)
+
}
+
+
expectedHash := sha256.Sum256(canonicalJSON)
+
expectedThumbprint := base64.RawURLEncoding.EncodeToString(expectedHash[:])
+
+
actualThumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed: %v", err)
+
}
+
+
if actualThumbprint != expectedThumbprint {
+
t.Errorf("Thumbprint doesn't match expected canonical JSON hash\nExpected: %s\nGot: %s",
+
expectedThumbprint, actualThumbprint)
+
}
+
}
+
+
// === DPoP Proof Verification Tests ===
+
+
func TestVerifyDPoPProof_Valid(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for valid proof: %v", err)
+
}
+
+
if result == nil {
+
t.Fatal("Expected non-nil proof result")
+
}
+
+
if result.Claims.HTTPMethod != method {
+
t.Errorf("Expected method %s, got %s", method, result.Claims.HTTPMethod)
+
}
+
+
if result.Claims.HTTPURI != uri {
+
t.Errorf("Expected URI %s, got %s", uri, result.Claims.HTTPURI)
+
}
+
+
if result.Claims.ID != jti {
+
t.Errorf("Expected jti %s, got %s", jti, result.Claims.ID)
+
}
+
+
if result.Thumbprint != key.thumbprint {
+
t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint)
+
}
+
}
+
+
func TestVerifyDPoPProof_InvalidSignature(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
wrongKey := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
// Create proof with one key
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
// Parse and modify to use wrong key's JWK in header (signature won't match)
+
parts := splitJWT(proof)
+
header := parseJWTHeader(t, parts[0])
+
header["jwk"] = wrongKey.jwk
+
modifiedHeader := encodeJSON(t, header)
+
tamperedProof := modifiedHeader + "." + parts[1] + "." + parts[2]
+
+
_, err := verifier.VerifyDPoPProof(tamperedProof, method, uri)
+
if err == nil {
+
t.Error("Expected error for invalid signature, got nil")
+
}
+
if err != nil && !contains(err.Error(), "signature verification failed") {
+
t.Errorf("Expected signature verification error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongHTTPMethod(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
wrongMethod := "GET"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, wrongMethod, uri)
+
if err == nil {
+
t.Error("Expected error for HTTP method mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "htm mismatch") {
+
t.Errorf("Expected htm mismatch error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongURI(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
wrongURI := "https://api.example.com/different"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, wrongURI)
+
if err == nil {
+
t.Error("Expected error for URI mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "htu mismatch") {
+
t.Errorf("Expected htu mismatch error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_URIWithQuery(t *testing.T) {
+
// URI comparison should strip query and fragment
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
baseURI := "https://api.example.com/resource"
+
uriWithQuery := baseURI + "?param=value"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, baseURI, iat, jti)
+
+
// Should succeed because query is stripped
+
_, err := verifier.VerifyDPoPProof(proof, method, uriWithQuery)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for URI with query: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_URIWithFragment(t *testing.T) {
+
// URI comparison should strip query and fragment
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
baseURI := "https://api.example.com/resource"
+
uriWithFragment := baseURI + "#section"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, baseURI, iat, jti)
+
+
// Should succeed because fragment is stripped
+
_, err := verifier.VerifyDPoPProof(proof, method, uriWithFragment)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for URI with fragment: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_ExpiredProof(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 10 minutes ago (exceeds default MaxProofAge of 5 minutes)
+
iat := time.Now().Add(-10 * time.Minute)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for expired proof, got nil")
+
}
+
if err != nil && !contains(err.Error(), "too old") {
+
t.Errorf("Expected 'too old' error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_FutureProof(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 1 minute in the future (exceeds MaxClockSkew)
+
iat := time.Now().Add(1 * time.Minute)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for future proof, got nil")
+
}
+
if err != nil && !contains(err.Error(), "in the future") {
+
t.Errorf("Expected 'in the future' error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WithinClockSkew(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 15 seconds in the future (within MaxClockSkew of 30s)
+
iat := time.Now().Add(15 * time.Second)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for proof within clock skew: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingJti(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
// No ID (jti)
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing jti, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jti") {
+
t.Errorf("Expected missing jti error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingTypHeader(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
// Don't set typ header
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing typ header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") {
+
t.Errorf("Expected typ header error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_WrongTypHeader(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "JWT" // Wrong typ
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for wrong typ header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "typ must be 'dpop+jwt'") {
+
t.Errorf("Expected typ header error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_MissingJWK(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
// Don't include JWK
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for missing jwk header, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jwk") {
+
t.Errorf("Expected missing jwk error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_CustomTimeSettings(t *testing.T) {
+
verifier := &DPoPVerifier{
+
MaxClockSkew: 1 * time.Minute,
+
MaxProofAge: 10 * time.Minute,
+
}
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
// Proof issued 50 seconds in the future (within custom MaxClockSkew)
+
iat := time.Now().Add(50 * time.Second)
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
_, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed with custom time settings: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_HTTPMethodCaseInsensitive(t *testing.T) {
+
// HTTP method comparison should be case-insensitive per spec
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "post"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
// Verify with uppercase method
+
_, err := verifier.VerifyDPoPProof(proof, "POST", uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for case-insensitive method: %v", err)
+
}
+
}
+
+
// === Token Binding Verification Tests ===
+
+
func TestVerifyTokenBinding_Matching(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed: %v", err)
+
}
+
+
// Verify token binding with matching thumbprint
+
err = verifier.VerifyTokenBinding(result, key.thumbprint)
+
if err != nil {
+
t.Fatalf("VerifyTokenBinding failed for matching thumbprint: %v", err)
+
}
+
}
+
+
func TestVerifyTokenBinding_Mismatch(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
wrongKey := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed: %v", err)
+
}
+
+
// Verify token binding with wrong thumbprint
+
err = verifier.VerifyTokenBinding(result, wrongKey.thumbprint)
+
if err == nil {
+
t.Error("Expected error for thumbprint mismatch, got nil")
+
}
+
if err != nil && !contains(err.Error(), "thumbprint mismatch") {
+
t.Errorf("Expected thumbprint mismatch error, got: %v", err)
+
}
+
}
+
+
// === ExtractCnfJkt Tests ===
+
+
func TestExtractCnfJkt_Valid(t *testing.T) {
+
expectedJkt := "test-thumbprint-123"
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": expectedJkt,
+
},
+
}
+
+
jkt, err := ExtractCnfJkt(claims)
+
if err != nil {
+
t.Fatalf("ExtractCnfJkt failed for valid claims: %v", err)
+
}
+
+
if jkt != expectedJkt {
+
t.Errorf("Expected jkt %s, got %s", expectedJkt, jkt)
+
}
+
}
+
+
func TestExtractCnfJkt_MissingCnf(t *testing.T) {
+
claims := &Claims{
+
// No Confirmation
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for missing cnf, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing cnf claim") {
+
t.Errorf("Expected missing cnf error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_NilCnf(t *testing.T) {
+
claims := &Claims{
+
Confirmation: nil,
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for nil cnf, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing cnf claim") {
+
t.Errorf("Expected missing cnf error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_MissingJkt(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"other": "value",
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for missing jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_EmptyJkt(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": "",
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for empty jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
func TestExtractCnfJkt_WrongType(t *testing.T) {
+
claims := &Claims{
+
Confirmation: map[string]interface{}{
+
"jkt": 123, // Not a string
+
},
+
}
+
+
_, err := ExtractCnfJkt(claims)
+
if err == nil {
+
t.Error("Expected error for wrong type jkt, got nil")
+
}
+
if err != nil && !contains(err.Error(), "missing jkt") {
+
t.Errorf("Expected missing jkt error, got: %v", err)
+
}
+
}
+
+
// === Helper Functions for Tests ===
+
+
// splitJWT splits a JWT into its three parts
+
func splitJWT(token string) []string {
+
return []string{
+
token[:strings.IndexByte(token, '.')],
+
token[strings.IndexByte(token, '.')+1 : strings.LastIndexByte(token, '.')],
+
token[strings.LastIndexByte(token, '.')+1:],
+
}
+
}
+
+
// parseJWTHeader parses a base64url-encoded JWT header
+
func parseJWTHeader(t *testing.T, encoded string) map[string]interface{} {
+
t.Helper()
+
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
+
if err != nil {
+
t.Fatalf("Failed to decode header: %v", err)
+
}
+
+
var header map[string]interface{}
+
if err := json.Unmarshal(decoded, &header); err != nil {
+
t.Fatalf("Failed to unmarshal header: %v", err)
+
}
+
+
return header
+
}
+
+
// encodeJSON encodes a value to base64url-encoded JSON
+
func encodeJSON(t *testing.T, v interface{}) string {
+
t.Helper()
+
data, err := json.Marshal(v)
+
if err != nil {
+
t.Fatalf("Failed to marshal JSON: %v", err)
+
}
+
return base64.RawURLEncoding.EncodeToString(data)
+
}
+148 -6
internal/api/middleware/auth.go
···
import (
"Coves/internal/atproto/auth"
"context"
+
"fmt"
"log"
"net/http"
"strings"
···
UserDIDKey contextKey = "user_did"
JWTClaimsKey contextKey = "jwt_claims"
UserAccessToken contextKey = "user_access_token"
+
DPoPProofKey contextKey = "dpop_proof"
)
// AtProtoAuthMiddleware enforces atProto OAuth authentication for protected routes
// Validates JWT Bearer tokens from the Authorization header
+
// Supports DPoP (RFC 9449) for token binding verification
type AtProtoAuthMiddleware struct {
-
jwksFetcher auth.JWKSFetcher
-
skipVerify bool // For Phase 1 testing only
+
jwksFetcher auth.JWKSFetcher
+
dpopVerifier *auth.DPoPVerifier
+
skipVerify bool // For Phase 1 testing only
}
// NewAtProtoAuthMiddleware creates a new atProto auth middleware
// skipVerify: if true, only parses JWT without signature verification (Phase 1)
//
// if false, performs full signature verification (Phase 2)
+
//
+
// IMPORTANT: Call Stop() when shutting down to clean up background goroutines.
func NewAtProtoAuthMiddleware(jwksFetcher auth.JWKSFetcher, skipVerify bool) *AtProtoAuthMiddleware {
return &AtProtoAuthMiddleware{
-
jwksFetcher: jwksFetcher,
-
skipVerify: skipVerify,
+
jwksFetcher: jwksFetcher,
+
dpopVerifier: auth.NewDPoPVerifier(),
+
skipVerify: skipVerify,
+
}
+
}
+
+
// Stop stops background goroutines. Call this when shutting down the server.
+
// This prevents goroutine leaks from the DPoP verifier's replay protection cache.
+
func (m *AtProtoAuthMiddleware) Stop() {
+
if m.dpopVerifier != nil {
+
m.dpopVerifier.Stop()
}
}
···
}
} else {
// Phase 2: Full verification with signature check
+
//
+
// SECURITY: The access token MUST be verified before trusting any claims.
+
// DPoP is an ADDITIONAL security layer, not a replacement for signature verification.
claims, err = auth.VerifyJWT(r.Context(), token, m.jwksFetcher)
if err != nil {
-
// Try to extract issuer for better logging
+
// Token verification failed - REJECT
+
// DO NOT fall back to DPoP-only verification, as that would trust unverified claims
issuer := "unknown"
if parsedClaims, parseErr := auth.ParseJWT(token); parseErr == nil {
issuer = parsedClaims.Issuer
···
writeAuthError(w, "Invalid or expired token")
return
}
+
+
// Token signature verified - now check if DPoP binding is required
+
// If the token has a cnf.jkt claim, DPoP proof is REQUIRED
+
dpopHeader := r.Header.Get("DPoP")
+
hasCnfJkt := claims.Confirmation != nil && claims.Confirmation["jkt"] != nil
+
+
if hasCnfJkt {
+
// Token has DPoP binding - REQUIRE valid DPoP proof
+
if dpopHeader == "" {
+
log.Printf("[AUTH_FAILURE] type=missing_dpop ip=%s method=%s path=%s error=token has cnf.jkt but no DPoP header",
+
r.RemoteAddr, r.Method, r.URL.Path)
+
writeAuthError(w, "DPoP proof required")
+
return
+
}
+
+
proof, err := m.verifyDPoPBinding(r, claims, dpopHeader)
+
if err != nil {
+
log.Printf("[AUTH_FAILURE] type=dpop_verification_failed ip=%s method=%s path=%s error=%v",
+
r.RemoteAddr, r.Method, r.URL.Path, err)
+
writeAuthError(w, "Invalid DPoP proof")
+
return
+
}
+
+
// Store verified DPoP proof in context
+
ctx := context.WithValue(r.Context(), DPoPProofKey, proof)
+
r = r.WithContext(ctx)
+
} else if dpopHeader != "" {
+
// DPoP header present but token doesn't have cnf.jkt - this is suspicious
+
// Log warning but don't reject (could be a misconfigured client)
+
log.Printf("[AUTH_WARNING] type=unexpected_dpop ip=%s method=%s path=%s warning=DPoP header present but token has no cnf.jkt",
+
r.RemoteAddr, r.Method, r.URL.Path)
+
}
}
// Extract user DID from 'sub' claim
···
claims, err = auth.ParseJWT(token)
} else {
// Phase 2: Full verification
+
// SECURITY: Token MUST be verified before trusting claims
claims, err = auth.VerifyJWT(r.Context(), token, m.jwksFetcher)
}
···
return
}
-
// Inject user info and access token into context
+
// Check DPoP binding if token has cnf.jkt (after successful verification)
+
// SECURITY: If token has cnf.jkt but no DPoP header, we cannot trust it
+
// (could be a stolen token). Continue as unauthenticated.
+
if !m.skipVerify {
+
dpopHeader := r.Header.Get("DPoP")
+
hasCnfJkt := claims.Confirmation != nil && claims.Confirmation["jkt"] != nil
+
+
if hasCnfJkt {
+
if dpopHeader == "" {
+
// Token requires DPoP binding but no proof provided
+
// Cannot trust this token - continue without auth
+
log.Printf("[AUTH_WARNING] Optional auth: token has cnf.jkt but no DPoP header - treating as unauthenticated (potential token theft)")
+
next.ServeHTTP(w, r)
+
return
+
}
+
+
proof, err := m.verifyDPoPBinding(r, claims, dpopHeader)
+
if err != nil {
+
// DPoP verification failed - cannot trust this token
+
log.Printf("[AUTH_WARNING] Optional auth: DPoP verification failed - treating as unauthenticated: %v", err)
+
next.ServeHTTP(w, r)
+
return
+
}
+
+
// DPoP verified - inject proof into context
+
ctx := context.WithValue(r.Context(), UserDIDKey, claims.Subject)
+
ctx = context.WithValue(ctx, JWTClaimsKey, claims)
+
ctx = context.WithValue(ctx, UserAccessToken, token)
+
ctx = context.WithValue(ctx, DPoPProofKey, proof)
+
next.ServeHTTP(w, r.WithContext(ctx))
+
return
+
}
+
}
+
+
// No DPoP binding required - inject user info and access token into context
ctx := context.WithValue(r.Context(), UserDIDKey, claims.Subject)
ctx = context.WithValue(ctx, JWTClaimsKey, claims)
ctx = context.WithValue(ctx, UserAccessToken, token)
···
return token
}
+
// GetDPoPProof extracts the DPoP proof from the request context
+
// Returns nil if no DPoP proof was verified
+
func GetDPoPProof(r *http.Request) *auth.DPoPProof {
+
proof, _ := r.Context().Value(DPoPProofKey).(*auth.DPoPProof)
+
return proof
+
}
+
+
// verifyDPoPBinding verifies DPoP proof binding for an ALREADY VERIFIED token.
+
//
+
// SECURITY: This function ONLY verifies the DPoP proof and its binding to the token.
+
// The access token MUST be signature-verified BEFORE calling this function.
+
// DPoP is an ADDITIONAL security layer, not a replacement for signature verification.
+
//
+
// This prevents token theft attacks by proving the client possesses the private key
+
// corresponding to the public key thumbprint in the token's cnf.jkt claim.
+
func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader string) (*auth.DPoPProof, error) {
+
// Extract the cnf.jkt claim from the already-verified token
+
jkt, err := auth.ExtractCnfJkt(claims)
+
if err != nil {
+
return nil, fmt.Errorf("token requires DPoP but missing cnf.jkt: %w", err)
+
}
+
+
// Build the HTTP URI for DPoP verification
+
// Use the full URL including scheme and host
+
scheme := strings.TrimSpace(r.URL.Scheme)
+
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
+
// Forwarded proto may contain a comma-separated list; use the first entry
+
parts := strings.Split(forwardedProto, ",")
+
if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" {
+
scheme = strings.ToLower(strings.TrimSpace(parts[0]))
+
}
+
}
+
if scheme == "" {
+
if r.TLS != nil {
+
scheme = "https"
+
} else {
+
scheme = "http"
+
}
+
}
+
scheme = strings.ToLower(scheme)
+
httpURI := scheme + "://" + r.Host + r.URL.Path
+
+
// Verify the DPoP proof
+
proof, err := m.dpopVerifier.VerifyDPoPProof(dpopProofHeader, r.Method, httpURI)
+
if err != nil {
+
return nil, fmt.Errorf("DPoP proof verification failed: %w", err)
+
}
+
+
// Verify the binding between the proof and the token
+
if err := m.dpopVerifier.VerifyTokenBinding(proof, jkt); err != nil {
+
return nil, fmt.Errorf("DPoP binding verification failed: %w", err)
+
}
+
+
return proof, nil
+
}
+
// writeAuthError writes a JSON error response for authentication failures
func writeAuthError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
+416
internal/api/middleware/auth_test.go
···
package middleware
import (
+
"Coves/internal/atproto/auth"
"context"
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/rand"
+
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
···
"time"
"github.com/golang-jwt/jwt/v5"
+
"github.com/google/uuid"
)
// mockJWKSFetcher is a test double for JWKSFetcher
···
t.Errorf("expected nil claims, got %+v", claims)
}
}
+
+
// TestGetDPoPProof_NotAuthenticated tests that GetDPoPProof returns nil when no DPoP was verified
+
func TestGetDPoPProof_NotAuthenticated(t *testing.T) {
+
req := httptest.NewRequest("GET", "/test", nil)
+
proof := GetDPoPProof(req)
+
+
if proof != nil {
+
t.Errorf("expected nil proof, got %+v", proof)
+
}
+
}
+
+
// TestRequireAuth_WithDPoP_SecurityModel tests the correct DPoP security model:
+
// Token MUST be verified first, then DPoP is checked as an additional layer.
+
// DPoP is NOT a fallback for failed token verification.
+
func TestRequireAuth_WithDPoP_SecurityModel(t *testing.T) {
+
// Generate an ECDSA key pair for DPoP
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
// Calculate JWK thumbprint for cnf.jkt
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("failed to calculate thumbprint: %v", err)
+
}
+
+
t.Run("DPoP_is_NOT_fallback_for_failed_verification", func(t *testing.T) {
+
// SECURITY TEST: When token verification fails, DPoP should NOT be used as fallback
+
// This prevents an attacker from forging a token with their own cnf.jkt
+
+
// Create a DPoP-bound access token (unsigned - will fail verification)
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:attacker",
+
Issuer: "https://external.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+
// Create valid DPoP proof (attacker has the private key)
+
dpopProof := createDPoPProof(t, privateKey, "GET", "https://test.local/api/endpoint")
+
+
// Mock fetcher that fails (simulating external PDS without JWKS)
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false
+
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
t.Error("SECURITY VULNERABILITY: handler was called despite token verification failure")
+
}))
+
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("DPoP", dpopProof)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// MUST reject - token verification failed, DPoP cannot substitute for signature verification
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("SECURITY: expected 401 for unverified token, got %d", w.Code)
+
}
+
})
+
+
t.Run("DPoP_required_when_cnf_jkt_present_in_verified_token", func(t *testing.T) {
+
// When token has cnf.jkt, DPoP header MUST be present
+
// This test uses skipVerify=true to simulate a verified token
+
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:test123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+
// NO DPoP header - should fail when skipVerify is false
+
// Note: with skipVerify=true, DPoP is not checked
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, true) // skipVerify=true for parsing
+
+
handlerCalled := false
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "https://test.local/api/endpoint", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// No DPoP header
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// With skipVerify=true, DPoP is not checked, so this should succeed
+
if !handlerCalled {
+
t.Error("handler should be called when skipVerify=true")
+
}
+
})
+
}
+
+
// TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback is the key security test.
+
// It ensures that DPoP cannot be used as a fallback when token signature verification fails.
+
func TestRequireAuth_TokenVerificationFails_DPoPNotUsedAsFallback(t *testing.T) {
+
// Generate a key pair (attacker's key)
+
attackerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
jwk := ecdsaPublicKeyToJWK(&attackerKey.PublicKey)
+
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
+
+
// Create a FORGED token claiming to be the victim
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:victim_user", // Attacker claims to be victim
+
Issuer: "https://untrusted.pds",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint, // Attacker uses their own key
+
},
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+
// Attacker creates a valid DPoP proof with their key
+
dpopProof := createDPoPProof(t, attackerKey, "POST", "https://api.example.com/protected")
+
+
// Fetcher fails (external PDS without JWKS)
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false - REAL verification
+
+
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
t.Fatalf("CRITICAL SECURITY FAILURE: Request authenticated as %s despite forged token!",
+
GetUserDID(r))
+
}))
+
+
req := httptest.NewRequest("POST", "https://api.example.com/protected", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
req.Header.Set("DPoP", dpopProof)
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// MUST reject - the token signature was never verified
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("SECURITY VULNERABILITY: Expected 401, got %d. Token was not properly verified!", w.Code)
+
}
+
}
+
+
// TestVerifyDPoPBinding_UsesForwardedProto ensures we honor the external HTTPS
+
// scheme when TLS is terminated upstream and X-Forwarded-Proto is present.
+
func TestVerifyDPoPBinding_UsesForwardedProto(t *testing.T) {
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
t.Fatalf("failed to generate key: %v", err)
+
}
+
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, err := auth.CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("failed to calculate thumbprint: %v", err)
+
}
+
+
claims := &auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:test123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
middleware := NewAtProtoAuthMiddleware(&mockJWKSFetcher{}, false)
+
defer middleware.Stop()
+
+
externalURI := "https://api.example.com/protected/resource"
+
dpopProof := createDPoPProof(t, privateKey, "GET", externalURI)
+
+
req := httptest.NewRequest("GET", "http://internal-service/protected/resource", nil)
+
req.Host = "api.example.com"
+
req.Header.Set("X-Forwarded-Proto", "https")
+
+
proof, err := middleware.verifyDPoPBinding(req, claims, dpopProof)
+
if err != nil {
+
t.Fatalf("expected DPoP verification to succeed with forwarded proto, got %v", err)
+
}
+
+
if proof == nil || proof.Claims == nil {
+
t.Fatal("expected DPoP proof to be returned")
+
}
+
}
+
+
// TestMiddlewareStop tests that the middleware can be stopped properly
+
func TestMiddlewareStop(t *testing.T) {
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false)
+
+
// Stop should not panic and should clean up resources
+
middleware.Stop()
+
+
// Calling Stop again should also be safe (idempotent-ish)
+
// Note: The underlying DPoPVerifier.Stop() closes a channel, so this might panic
+
// if not handled properly. We test that at least one Stop works.
+
}
+
+
// TestOptionalAuth_DPoPBoundToken_NoDPoPHeader tests that OptionalAuth treats
+
// tokens with cnf.jkt but no DPoP header as unauthenticated (potential token theft)
+
func TestOptionalAuth_DPoPBoundToken_NoDPoPHeader(t *testing.T) {
+
// Generate a key pair for DPoP binding
+
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
thumbprint, _ := auth.CalculateJWKThumbprint(jwk)
+
+
// Create a DPoP-bound token (has cnf.jkt)
+
claims := auth.Claims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
Subject: "did:plc:user123",
+
Issuer: "https://test.pds.local",
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
},
+
Scope: "atproto",
+
Confirmation: map[string]interface{}{
+
"jkt": thumbprint,
+
},
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+
tokenString, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+
// Use skipVerify=true to simulate a verified token
+
// (In production, skipVerify would be false and VerifyJWT would be called)
+
// However, for this test we need skipVerify=false to trigger DPoP checking
+
// But the fetcher will fail, so let's use skipVerify=true and verify the logic
+
// Actually, the DPoP check only happens when skipVerify=false
+
+
t.Run("with_skipVerify_false", func(t *testing.T) {
+
// This will fail at JWT verification level, but that's expected
+
// The important thing is the code path for DPoP checking
+
fetcher := &mockJWKSFetcher{shouldFail: true}
+
middleware := NewAtProtoAuthMiddleware(fetcher, false)
+
defer middleware.Stop()
+
+
handlerCalled := false
+
var capturedDID string
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
capturedDID = GetUserDID(r)
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// Deliberately NOT setting DPoP header
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
// Handler should be called (optional auth doesn't block)
+
if !handlerCalled {
+
t.Error("handler should be called")
+
}
+
+
// But since JWT verification fails, user should not be authenticated
+
if capturedDID != "" {
+
t.Errorf("expected empty DID when verification fails, got %s", capturedDID)
+
}
+
})
+
+
t.Run("with_skipVerify_true_dpop_not_checked", func(t *testing.T) {
+
// When skipVerify=true, DPoP is not checked (Phase 1 mode)
+
fetcher := &mockJWKSFetcher{}
+
middleware := NewAtProtoAuthMiddleware(fetcher, true)
+
defer middleware.Stop()
+
+
handlerCalled := false
+
var capturedDID string
+
handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
handlerCalled = true
+
capturedDID = GetUserDID(r)
+
w.WriteHeader(http.StatusOK)
+
}))
+
+
req := httptest.NewRequest("GET", "/test", nil)
+
req.Header.Set("Authorization", "Bearer "+tokenString)
+
// No DPoP header
+
w := httptest.NewRecorder()
+
+
handler.ServeHTTP(w, req)
+
+
if !handlerCalled {
+
t.Error("handler should be called")
+
}
+
+
// With skipVerify=true, DPoP check is bypassed - token is trusted
+
if capturedDID != "did:plc:user123" {
+
t.Errorf("expected DID when skipVerify=true, got %s", capturedDID)
+
}
+
})
+
}
+
+
// TestDPoPReplayProtection tests that the same DPoP proof cannot be used twice
+
func TestDPoPReplayProtection(t *testing.T) {
+
// This tests the NonceCache functionality
+
cache := auth.NewNonceCache(5 * time.Minute)
+
defer cache.Stop()
+
+
jti := "unique-proof-id-123"
+
+
// First use should succeed
+
if !cache.CheckAndStore(jti) {
+
t.Error("First use of jti should succeed")
+
}
+
+
// Second use should fail (replay detected)
+
if cache.CheckAndStore(jti) {
+
t.Error("SECURITY: Replay attack not detected - same jti accepted twice")
+
}
+
+
// Different jti should succeed
+
if !cache.CheckAndStore("different-jti-456") {
+
t.Error("Different jti should succeed")
+
}
+
}
+
+
// Helper: createDPoPProof creates a DPoP proof JWT for testing
+
func createDPoPProof(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri string) string {
+
// Create JWK from public key
+
jwk := ecdsaPublicKeyToJWK(&privateKey.PublicKey)
+
+
// Create DPoP claims with UUID for jti to ensure uniqueness across tests
+
claims := auth.DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
IssuedAt: jwt.NewNumericDate(time.Now()),
+
ID: uuid.New().String(),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
// Create token with custom header
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = jwk
+
+
// Sign with private key
+
signedToken, err := token.SignedString(privateKey)
+
if err != nil {
+
t.Fatalf("failed to sign DPoP proof: %v", err)
+
}
+
+
return signedToken
+
}
+
+
// Helper: ecdsaPublicKeyToJWK converts an ECDSA public key to JWK map
+
func ecdsaPublicKeyToJWK(pubKey *ecdsa.PublicKey) map[string]interface{} {
+
// Get curve name
+
var crv string
+
switch pubKey.Curve {
+
case elliptic.P256():
+
crv = "P-256"
+
case elliptic.P384():
+
crv = "P-384"
+
case elliptic.P521():
+
crv = "P-521"
+
default:
+
panic("unsupported curve")
+
}
+
+
// Encode coordinates
+
xBytes := pubKey.X.Bytes()
+
yBytes := pubKey.Y.Bytes()
+
+
// Ensure proper byte length (pad if needed)
+
keySize := (pubKey.Curve.Params().BitSize + 7) / 8
+
xPadded := make([]byte, keySize)
+
yPadded := make([]byte, keySize)
+
copy(xPadded[keySize-len(xBytes):], xBytes)
+
copy(yPadded[keySize-len(yBytes):], yBytes)
+
+
return map[string]interface{}{
+
"kty": "EC",
+
"crv": crv,
+
"x": base64.RawURLEncoding.EncodeToString(xPadded),
+
"y": base64.RawURLEncoding.EncodeToString(yPadded),
+
}
+
}
+134 -2
internal/atproto/auth/README.md
···
5. Find matching key by `kid` from JWT header
6. Cache the JWKS for 1 hour
+
## DPoP Token Binding
+
+
DPoP (Demonstrating Proof-of-Possession) binds access tokens to client-controlled cryptographic keys, preventing token theft and replay attacks.
+
+
### What is DPoP?
+
+
DPoP is an OAuth extension (RFC 9449) that adds proof-of-possession semantics to bearer tokens. When a PDS issues a DPoP-bound access token:
+
+
1. Access token contains `cnf.jkt` claim (JWK thumbprint of client's public key)
+
2. Client creates a DPoP proof JWT signed with their private key
+
3. Server verifies the proof signature and checks it matches the token's `cnf.jkt`
+
+
### CRITICAL: DPoP Security Model
+
+
> โš ๏ธ **DPoP is an ADDITIONAL security layer, NOT a replacement for token signature verification.**
+
+
The correct verification order is:
+
1. **ALWAYS verify the access token signature first** (via JWKS, HS256 shared secret, or DID resolution)
+
2. **If the verified token has `cnf.jkt`, REQUIRE valid DPoP proof**
+
3. **NEVER use DPoP as a fallback when signature verification fails**
+
+
**Why This Matters**: An attacker could create a fake token with `sub: "did:plc:victim"` and their own `cnf.jkt`, then present a valid DPoP proof signed with their key. If we accept DPoP as a fallback, the attacker can impersonate any user.
+
+
### How DPoP Works
+
+
```
+
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
+
โ”‚ Client โ”‚ โ”‚ Server โ”‚
+
โ”‚ โ”‚ โ”‚ (Coves) โ”‚
+
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
+
โ”‚ โ”‚
+
โ”‚ 1. Authorization: Bearer <token> โ”‚
+
โ”‚ DPoP: <proof-jwt> โ”‚
+
โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚
+
โ”‚ โ”‚
+
โ”‚ โ”‚ 2. VERIFY token signature
+
โ”‚ โ”‚ (REQUIRED - no fallback!)
+
โ”‚ โ”‚
+
โ”‚ โ”‚ 3. If token has cnf.jkt:
+
โ”‚ โ”‚ - Verify DPoP proof
+
โ”‚ โ”‚ - Check thumbprint match
+
โ”‚ โ”‚
+
โ”‚ 200 OK โ”‚
+
โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
+
```
+
+
### When DPoP is Required
+
+
DPoP verification is **REQUIRED** when:
+
- Access token signature has been verified AND
+
- Access token contains `cnf.jkt` claim (DPoP-bound)
+
+
If the token has `cnf.jkt` but no DPoP header is present, the request is **REJECTED**.
+
+
### Replay Protection
+
+
DPoP proofs include a unique `jti` (JWT ID) claim. The server tracks seen `jti` values to prevent replay attacks:
+
+
```go
+
// Create a verifier with replay protection (default)
+
verifier := auth.NewDPoPVerifier()
+
defer verifier.Stop() // Stop cleanup goroutine on shutdown
+
+
// The verifier automatically rejects reused jti values within the proof validity window (5 minutes)
+
```
+
+
### DPoP Implementation
+
+
The `dpop.go` module provides:
+
+
```go
+
// Create a verifier with replay protection
+
verifier := auth.NewDPoPVerifier()
+
defer verifier.Stop()
+
+
// Verify the DPoP proof
+
proof, err := verifier.VerifyDPoPProof(dpopHeader, "POST", "https://coves.social/xrpc/...")
+
if err != nil {
+
// Invalid proof (includes replay detection)
+
}
+
+
// Verify it binds to the VERIFIED access token
+
expectedThumbprint, err := auth.ExtractCnfJkt(claims)
+
if err != nil {
+
// Token not DPoP-bound
+
}
+
+
if err := verifier.VerifyTokenBinding(proof, expectedThumbprint); err != nil {
+
// Proof doesn't match token
+
}
+
```
+
+
### DPoP Proof Format
+
+
The DPoP header contains a JWT with:
+
+
**Header**:
+
- `typ`: `"dpop+jwt"` (required)
+
- `alg`: `"ES256"` (or other supported algorithm)
+
- `jwk`: Client's public key (JWK format)
+
+
**Claims**:
+
- `jti`: Unique proof identifier (tracked for replay protection)
+
- `htm`: HTTP method (e.g., `"POST"`)
+
- `htu`: HTTP URI (without query/fragment)
+
- `iat`: Timestamp (must be recent, within 5 minutes)
+
+
**Example**:
+
```json
+
{
+
"typ": "dpop+jwt",
+
"alg": "ES256",
+
"jwk": {
+
"kty": "EC",
+
"crv": "P-256",
+
"x": "...",
+
"y": "..."
+
}
+
}
+
{
+
"jti": "unique-id-123",
+
"htm": "POST",
+
"htu": "https://coves.social/xrpc/social.coves.community.create",
+
"iat": 1700000000
+
}
+
```
+
## Security Considerations
### โœ… Implemented
···
- Required claims validation (sub, iss)
- Key caching with TTL
- Secure error messages (no internal details leaked)
+
- **DPoP proof verification** (proof-of-possession for token binding)
+
- **DPoP thumbprint validation** (prevents token theft attacks)
+
- **DPoP freshness checks** (5-minute proof validity window)
+
- **DPoP replay protection** (jti tracking with in-memory cache)
+
- **Secure DPoP model** (DPoP required AFTER signature verification, never as fallback)
### โš ๏ธ Not Yet Implemented
-
- DPoP validation (for replay attack prevention)
+
- Server-issued DPoP nonces (additional replay protection)
- Scope validation (checking `scope` claim)
- Audience validation (checking `aud` claim)
- Rate limiting per DID
···
## Future Enhancements
-
- [ ] DPoP proof validation
+
- [ ] DPoP nonce validation (server-managed nonce for additional replay protection)
- [ ] Scope-based authorization
- [ ] Audience claim validation
- [ ] Token revocation support
+5 -6
go.mod
···
module Coves
-
go 1.24.0
+
go 1.25
require (
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36
github.com/go-chi/chi/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
···
github.com/lestrrat-go/jwx/v2 v2.0.12
github.com/lib/pq v1.10.9
github.com/pressly/goose/v3 v3.22.1
-
github.com/stretchr/testify v1.9.0
+
github.com/stretchr/testify v1.10.0
+
github.com/xeipuuv/gojsonschema v1.2.0
golang.org/x/net v0.46.0
golang.org/x/time v0.3.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
-
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
···
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
-
github.com/stretchr/objx v0.5.2 // indirect
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
-
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
+6 -8
go.sum
···
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=
-
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
-
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
-
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36 h1:Vc+l4sltxQfBT8qC3dm87PRYInmxlGyF1dmpjaW0WkU=
+
github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
···
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
+
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
···
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
···
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=