diff --git a/cmd/server/main.go b/cmd/server/main.go index 3d61a83..22715ab 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,18 @@ 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" @@ -16,17 +28,6 @@ import ( "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" @@ -184,6 +185,20 @@ func main() { 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 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, defaultPDS) @@ -422,7 +437,7 @@ func main() { // Register XRPC routes routes.RegisterUserRoutes(r, userService) - routes.RegisterCommunityRoutes(r, communityService, authMiddleware) + routes.RegisterCommunityRoutes(r, communityService, authMiddleware, allowedCommunityCreators) log.Println("Community XRPC endpoints registered with OAuth authentication") routes.RegisterPostRoutes(r, postService, authMiddleware) diff --git a/internal/api/handlers/community/create.go b/internal/api/handlers/community/create.go index 1283b2b..f9ac0c6 100644 --- a/internal/api/handlers/community/create.go +++ b/internal/api/handlers/community/create.go @@ -1,21 +1,38 @@ package community import ( - "Coves/internal/api/middleware" - "Coves/internal/core/communities" "encoding/json" "net/http" + + "Coves/internal/api/middleware" + "Coves/internal/core/communities" ) // CreateHandler handles community creation type CreateHandler struct { - service communities.Service + service communities.Service + allowedCommunityCreators map[string]bool // nil = allow all } // NewCreateHandler creates a new create handler -func NewCreateHandler(service communities.Service) *CreateHandler { +// 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, + service: service, + allowedCommunityCreators: allowedMap, } } @@ -42,6 +59,13 @@ func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { 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 + } + // Client should not send createdByDid - we derive it from authenticated user if req.CreatedByDID != "" { writeError(w, http.StatusBadRequest, "InvalidRequest", diff --git a/internal/api/handlers/community/create_test.go b/internal/api/handlers/community/create_test.go new file mode 100644 index 0000000..bd572ad --- /dev/null +++ b/internal/api/handlers/community/create_test.go @@ -0,0 +1,265 @@ +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) + } +} diff --git a/internal/api/routes/community.go b/internal/api/routes/community.go index 895a2a5..fbacda7 100644 --- a/internal/api/routes/community.go +++ b/internal/api/routes/community.go @@ -10,9 +10,10 @@ import ( // 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) diff --git a/tests/integration/community_e2e_test.go b/tests/integration/community_e2e_test.go index 66b234e..e6a6521 100644 --- a/tests/integration/community_e2e_test.go +++ b/tests/integration/community_e2e_test.go @@ -1,14 +1,6 @@ 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" @@ -23,6 +15,15 @@ import ( "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" @@ -147,7 +148,7 @@ func TestCommunity_E2E(t *testing.T) { // Setup HTTP server with XRPC routes r := chi.NewRouter() - routes.RegisterCommunityRoutes(r, communityService, authMiddleware) + routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators httpServer := httptest.NewServer(r) defer httpServer.Close() diff --git a/tests/integration/user_journey_e2e_test.go b/tests/integration/user_journey_e2e_test.go index cac3860..727a04a 100644 --- a/tests/integration/user_journey_e2e_test.go +++ b/tests/integration/user_journey_e2e_test.go @@ -1,15 +1,6 @@ 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" @@ -23,6 +14,16 @@ import ( "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" _ "github.com/lib/pq" @@ -140,7 +141,7 @@ func TestFullUserJourney_E2E(t *testing.T) { // 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.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators routes.RegisterPostRoutes(r, postService, authMiddleware) routes.RegisterTimelineRoutes(r, timelineService, authMiddleware) httpServer := httptest.NewServer(r) @@ -795,7 +796,8 @@ func simulateCommunityIndexing(t *testing.T, db *sql.DB, did, handle, ownerDID s // 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) { + ctx context.Context, communityDID, authorDID, uri, cid, title, content string, +) { t.Helper() rkey := strings.Split(uri, "/")[4]