A community based topic aggregation platform built on atproto

feat: restrict community creation via COMMUNITY_CREATORS env var

Add configurable allowlist to restrict who can create communities during alpha.
Self-hosters can set their own DID in the env var.

- Add allowedCommunityCreators field to CreateHandler
- Load comma-separated DIDs from COMMUNITY_CREATORS env var
- Return 403 CommunityCreationRestricted for non-allowed users
- Empty/unset env var allows all authenticated users
- Filter empty strings from allowlist defensively
- Add comprehensive unit tests for allowlist behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+347 -39
cmd
server
internal
api
handlers
routes
tests
+27 -12
cmd/server/main.go
···
package main
import (
"Coves/internal/api/middleware"
"Coves/internal/api/routes"
"Coves/internal/atproto/auth"
···
"Coves/internal/core/timeline"
"Coves/internal/core/unfurl"
"Coves/internal/core/users"
-
"bytes"
-
"context"
-
"database/sql"
-
"encoding/json"
-
"fmt"
-
"io"
-
"log"
-
"net/http"
-
"os"
-
"strings"
-
"time"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
···
}
log.Printf("Instance domain: %s (extracted from DID: %s)", instanceDomain, instanceDID)
// V2.0: Initialize PDS account provisioner for communities (simplified)
// PDS handles all DID and key generation - no Coves-side cryptography needed
···
// Register XRPC routes
routes.RegisterUserRoutes(r, userService)
-
routes.RegisterCommunityRoutes(r, communityService, authMiddleware)
log.Println("Community XRPC endpoints registered with OAuth authentication")
routes.RegisterPostRoutes(r, postService, authMiddleware)
···
package main
import (
+
"bytes"
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"io"
+
"log"
+
"net/http"
+
"os"
+
"strings"
+
"time"
+
"Coves/internal/api/middleware"
"Coves/internal/api/routes"
"Coves/internal/atproto/auth"
···
"Coves/internal/core/timeline"
"Coves/internal/core/unfurl"
"Coves/internal/core/users"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
···
}
log.Printf("Instance domain: %s (extracted from DID: %s)", instanceDomain, instanceDID)
+
+
// Community creation restriction - if set, only these DIDs can create communities
+
var allowedCommunityCreators []string
+
if communityCreators := os.Getenv("COMMUNITY_CREATORS"); communityCreators != "" {
+
for _, did := range strings.Split(communityCreators, ",") {
+
did = strings.TrimSpace(did)
+
if did != "" {
+
allowedCommunityCreators = append(allowedCommunityCreators, did)
+
}
+
}
+
log.Printf("Community creation restricted to %d DIDs", len(allowedCommunityCreators))
+
} else {
+
log.Println("Community creation open to all authenticated users")
+
}
// V2.0: Initialize PDS account provisioner for communities (simplified)
// PDS handles all DID and key generation - no Coves-side cryptography needed
···
// Register XRPC routes
routes.RegisterUserRoutes(r, userService)
+
routes.RegisterCommunityRoutes(r, communityService, authMiddleware, allowedCommunityCreators)
log.Println("Community XRPC endpoints registered with OAuth authentication")
routes.RegisterPostRoutes(r, postService, authMiddleware)
+29 -5
internal/api/handlers/community/create.go
···
package community
import (
"Coves/internal/api/middleware"
"Coves/internal/core/communities"
-
"encoding/json"
-
"net/http"
)
// CreateHandler handles community creation
type CreateHandler struct {
-
service communities.Service
}
// NewCreateHandler creates a new create handler
-
func NewCreateHandler(service communities.Service) *CreateHandler {
return &CreateHandler{
-
service: service,
}
}
···
userDID := middleware.GetUserDID(r)
if userDID == "" {
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
return
}
···
package community
import (
+
"encoding/json"
+
"net/http"
+
"Coves/internal/api/middleware"
"Coves/internal/core/communities"
)
// CreateHandler handles community creation
type CreateHandler struct {
+
service communities.Service
+
allowedCommunityCreators map[string]bool // nil = allow all
}
// NewCreateHandler creates a new create handler
+
// allowedCreators is a list of DIDs that can create communities. If empty, anyone can create.
+
func NewCreateHandler(service communities.Service, allowedCreators []string) *CreateHandler {
+
var allowedMap map[string]bool
+
if len(allowedCreators) > 0 {
+
allowedMap = make(map[string]bool)
+
for _, did := range allowedCreators {
+
if did != "" { // Skip empty strings
+
allowedMap[did] = true
+
}
+
}
+
// If all entries were empty, treat as no restriction
+
if len(allowedMap) == 0 {
+
allowedMap = nil
+
}
+
}
return &CreateHandler{
+
service: service,
+
allowedCommunityCreators: allowedMap,
}
}
···
userDID := middleware.GetUserDID(r)
if userDID == "" {
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Check if user is allowed to create communities (if restriction is enabled)
+
if h.allowedCommunityCreators != nil && !h.allowedCommunityCreators[userDID] {
+
writeError(w, http.StatusForbidden, "CommunityCreationRestricted",
+
"Community creation is restricted to authorized users")
return
}
+265
internal/api/handlers/community/create_test.go
···
···
+
package community
+
+
import (
+
"bytes"
+
"context"
+
"encoding/json"
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
"time"
+
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/communities"
+
)
+
+
// mockCommunityService implements communities.Service for testing
+
type mockCommunityService struct {
+
createFunc func(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error)
+
}
+
+
func (m *mockCommunityService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) {
+
if m.createFunc != nil {
+
return m.createFunc(ctx, req)
+
}
+
return &communities.Community{
+
DID: "did:plc:test123",
+
Handle: "test.community.coves.social",
+
RecordURI: "at://did:plc:test123/social.coves.community.profile/self",
+
RecordCID: "bafytest123",
+
DisplayName: req.DisplayName,
+
Description: req.Description,
+
Visibility: req.Visibility,
+
CreatedAt: time.Now(),
+
}, nil
+
}
+
+
func (m *mockCommunityService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) {
+
return nil, 0, nil
+
}
+
+
func (m *mockCommunityService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {
+
return nil, 0, nil
+
}
+
+
func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error {
+
return nil
+
}
+
+
func (m *mockCommunityService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) BlockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) (*communities.CommunityBlock, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) UnblockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error {
+
return nil
+
}
+
+
func (m *mockCommunityService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) {
+
return false, nil
+
}
+
+
func (m *mockCommunityService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) {
+
return nil, nil
+
}
+
+
func (m *mockCommunityService) ValidateHandle(handle string) error {
+
return nil
+
}
+
+
func (m *mockCommunityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {
+
return identifier, nil
+
}
+
+
func (m *mockCommunityService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) {
+
return community, nil
+
}
+
+
func (m *mockCommunityService) GetByDID(ctx context.Context, did string) (*communities.Community, error) {
+
return nil, nil
+
}
+
+
func TestCreateHandler_AllowlistRestriction(t *testing.T) {
+
mockService := &mockCommunityService{}
+
+
tests := []struct {
+
name string
+
allowedDIDs []string
+
requestDID string
+
expectedStatus int
+
expectedError string
+
}{
+
{
+
name: "allowed DID can create community",
+
allowedDIDs: []string{"did:plc:allowed123"},
+
requestDID: "did:plc:allowed123",
+
expectedStatus: http.StatusOK,
+
},
+
{
+
name: "non-allowed DID is forbidden",
+
allowedDIDs: []string{"did:plc:allowed123"},
+
requestDID: "did:plc:notallowed456",
+
expectedStatus: http.StatusForbidden,
+
expectedError: "CommunityCreationRestricted",
+
},
+
{
+
name: "empty allowlist allows anyone",
+
allowedDIDs: nil,
+
requestDID: "did:plc:anyuser789",
+
expectedStatus: http.StatusOK,
+
},
+
{
+
name: "multiple allowed DIDs - first DID",
+
allowedDIDs: []string{"did:plc:admin1", "did:plc:admin2", "did:plc:admin3"},
+
requestDID: "did:plc:admin1",
+
expectedStatus: http.StatusOK,
+
},
+
{
+
name: "multiple allowed DIDs - last DID",
+
allowedDIDs: []string{"did:plc:admin1", "did:plc:admin2", "did:plc:admin3"},
+
requestDID: "did:plc:admin3",
+
expectedStatus: http.StatusOK,
+
},
+
{
+
name: "multiple allowed DIDs - not in list",
+
allowedDIDs: []string{"did:plc:admin1", "did:plc:admin2"},
+
requestDID: "did:plc:randomuser",
+
expectedStatus: http.StatusForbidden,
+
expectedError: "CommunityCreationRestricted",
+
},
+
{
+
name: "allowlist with empty strings filtered - valid DID works",
+
allowedDIDs: []string{"did:plc:admin1", "", "did:plc:admin2"},
+
requestDID: "did:plc:admin1",
+
expectedStatus: http.StatusOK,
+
},
+
{
+
name: "allowlist with empty strings filtered - invalid DID blocked",
+
allowedDIDs: []string{"did:plc:admin1", "", "did:plc:admin2"},
+
requestDID: "did:plc:notallowed",
+
expectedStatus: http.StatusForbidden,
+
expectedError: "CommunityCreationRestricted",
+
},
+
{
+
name: "all empty strings allows anyone",
+
allowedDIDs: []string{"", "", ""},
+
requestDID: "did:plc:anyuser",
+
expectedStatus: http.StatusOK,
+
},
+
}
+
+
for _, tc := range tests {
+
t.Run(tc.name, func(t *testing.T) {
+
handler := NewCreateHandler(mockService, tc.allowedDIDs)
+
+
// Create request body
+
reqBody := map[string]interface{}{
+
"name": "testcommunity",
+
"displayName": "Test Community",
+
"description": "Test description",
+
"visibility": "public",
+
"allowExternalDiscovery": true,
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
// Create HTTP request
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.create", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
+
// Inject user DID into context (simulates auth middleware)
+
ctx := context.WithValue(req.Context(), middleware.UserDIDKey, tc.requestDID)
+
req = req.WithContext(ctx)
+
+
// Execute handler
+
w := httptest.NewRecorder()
+
handler.HandleCreate(w, req)
+
+
// Check status code
+
if w.Code != tc.expectedStatus {
+
t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, w.Code, w.Body.String())
+
}
+
+
// Check error response if expected
+
if tc.expectedError != "" {
+
var errResp struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != tc.expectedError {
+
t.Errorf("Expected error %s, got %s", tc.expectedError, errResp.Error)
+
}
+
}
+
})
+
}
+
}
+
+
func TestCreateHandler_RequiresAuth(t *testing.T) {
+
mockService := &mockCommunityService{}
+
handler := NewCreateHandler(mockService, nil)
+
+
// Create request without auth context
+
reqBody := map[string]interface{}{
+
"name": "testcommunity",
+
"displayName": "Test Community",
+
"description": "Test description",
+
"visibility": "public",
+
}
+
bodyBytes, err := json.Marshal(reqBody)
+
if err != nil {
+
t.Fatalf("Failed to marshal request: %v", err)
+
}
+
+
req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.create", bytes.NewBuffer(bodyBytes))
+
req.Header.Set("Content-Type", "application/json")
+
// No user DID in context
+
+
w := httptest.NewRecorder()
+
handler.HandleCreate(w, req)
+
+
if w.Code != http.StatusUnauthorized {
+
t.Errorf("Expected status 401, got %d. Body: %s", w.Code, w.Body.String())
+
}
+
+
var errResp struct {
+
Error string `json:"error"`
+
}
+
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
+
t.Fatalf("Failed to decode error response: %v", err)
+
}
+
if errResp.Error != "AuthRequired" {
+
t.Errorf("Expected error AuthRequired, got %s", errResp.Error)
+
}
+
}
+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) {
// Initialize handlers
-
createHandler := community.NewCreateHandler(service)
getHandler := community.NewGetHandler(service)
updateHandler := community.NewUpdateHandler(service)
listHandler := community.NewListHandler(service)
···
// RegisterCommunityRoutes registers community-related XRPC endpoints on the router
// Implements social.coves.community.* lexicon endpoints
+
// 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, allowedCommunityCreators)
getHandler := community.NewGetHandler(service)
updateHandler := community.NewUpdateHandler(service)
listHandler := community.NewListHandler(service)
+10 -9
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"
···
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
···
// Setup HTTP server with XRPC routes
r := chi.NewRouter()
-
routes.RegisterCommunityRoutes(r, communityService, authMiddleware)
httpServer := httptest.NewServer(r)
defer httpServer.Close()
···
package integration
import (
"bytes"
"context"
"database/sql"
···
"strings"
"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"
···
// Setup HTTP server with XRPC routes
r := chi.NewRouter()
+
routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators
httpServer := httptest.NewServer(r)
defer httpServer.Close()
+13 -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"
-
timelineCore "Coves/internal/core/timeline"
-
"Coves/internal/core/users"
-
"Coves/internal/db/postgres"
"bytes"
"context"
"database/sql"
···
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
···
// Setup HTTP server with all routes
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing
r := chi.NewRouter()
-
routes.RegisterCommunityRoutes(r, communityService, authMiddleware)
routes.RegisterPostRoutes(r, postService, authMiddleware)
routes.RegisterTimelineRoutes(r, timelineService, authMiddleware)
httpServer := httptest.NewServer(r)
···
// Helper: Simulate post indexing for test speed
func simulatePostIndexing(t *testing.T, db *sql.DB, consumer *jetstream.PostEventConsumer,
-
ctx context.Context, communityDID, authorDID, uri, cid, title, content string) {
t.Helper()
rkey := strings.Split(uri, "/")[4]
···
package integration
import (
"bytes"
"context"
"database/sql"
···
"strings"
"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"
···
// Setup HTTP server with all routes
authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing
r := chi.NewRouter()
+
routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators
routes.RegisterPostRoutes(r, postService, authMiddleware)
routes.RegisterTimelineRoutes(r, timelineService, authMiddleware)
httpServer := httptest.NewServer(r)
···
// Helper: Simulate post indexing for test speed
func simulatePostIndexing(t *testing.T, db *sql.DB, consumer *jetstream.PostEventConsumer,
+
ctx context.Context, communityDID, authorDID, uri, cid, title, content string,
+
) {
t.Helper()
rkey := strings.Split(uri, "/")[4]