A community based topic aggregation platform built on atproto

feat(communities): Add XRPC HTTP endpoints for Communities

Endpoints implemented:
- GET /xrpc/social.coves.community.get - Retrieve by DID or handle
- GET /xrpc/social.coves.community.list - List with filters
- GET /xrpc/social.coves.community.search - Full-text search
- POST /xrpc/social.coves.community.create - Create community
- POST /xrpc/social.coves.community.subscribe - Subscribe to feed
- POST /xrpc/social.coves.community.unsubscribe - Unsubscribe

Security notes:
- TODO(Communities-OAuth): Authentication currently client-controlled
- MUST integrate OAuth middleware before production
- Authorization enforced at service layer
- Proper error mapping to HTTP status codes

Changed files
+486
internal
+72
internal/api/handlers/community/create.go
···
+
package community
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"Coves/internal/core/communities"
+
)
+
+
// 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,
+
}
+
}
+
+
// HandleCreate creates a new community
+
// POST /xrpc/social.coves.community.create
+
// Body matches CreateCommunityRequest
+
func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var req communities.CreateCommunityRequest
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// TODO(Communities-OAuth): Extract authenticated user DID from request context
+
// This MUST be replaced with OAuth middleware before production deployment
+
// Expected implementation:
+
// userDID := r.Context().Value("authenticated_user_did").(string)
+
// req.CreatedByDID = userDID
+
// For now, we require client to send it (INSECURE - allows impersonation)
+
if req.CreatedByDID == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
if req.HostedByDID == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "hostedByDid is required")
+
return
+
}
+
+
// Create community via service (write-forward to PDS)
+
community, err := h.service.CreateCommunity(r.Context(), req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response matching lexicon output
+
response := map[string]interface{}{
+
"uri": community.RecordURI,
+
"cid": community.RecordCID,
+
"did": community.DID,
+
"handle": community.Handle,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+47
internal/api/handlers/community/errors.go
···
+
package community
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"Coves/internal/core/communities"
+
)
+
+
// XRPCError represents an XRPC error response
+
type XRPCError struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
// writeError writes an XRPC error response
+
func writeError(w http.ResponseWriter, status int, error, message string) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(status)
+
json.NewEncoder(w).Encode(XRPCError{
+
Error: error,
+
Message: message,
+
})
+
}
+
+
// handleServiceError converts service errors to appropriate HTTP responses
+
func handleServiceError(w http.ResponseWriter, err error) {
+
switch {
+
case communities.IsNotFound(err):
+
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
case communities.IsConflict(err):
+
if err == communities.ErrHandleTaken {
+
writeError(w, http.StatusConflict, "NameTaken", "Community handle is already taken")
+
} else {
+
writeError(w, http.StatusConflict, "AlreadyExists", err.Error())
+
}
+
case communities.IsValidationError(err):
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
case err == communities.ErrUnauthorized:
+
writeError(w, http.StatusForbidden, "Forbidden", "You do not have permission to perform this action")
+
case err == communities.ErrMemberBanned:
+
writeError(w, http.StatusForbidden, "Blocked", "You are blocked from this community")
+
default:
+
// Internal server error
+
writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred")
+
}
+
}
+48
internal/api/handlers/community/get.go
···
+
package community
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"Coves/internal/core/communities"
+
)
+
+
// GetHandler handles community retrieval
+
type GetHandler struct {
+
service communities.Service
+
}
+
+
// NewGetHandler creates a new get handler
+
func NewGetHandler(service communities.Service) *GetHandler {
+
return &GetHandler{
+
service: service,
+
}
+
}
+
+
// HandleGet retrieves a community by DID or handle
+
// GET /xrpc/social.coves.community.get?community={did_or_handle}
+
func (h *GetHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodGet {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Get community identifier from query params
+
communityID := r.URL.Query().Get("community")
+
if communityID == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "community parameter is required")
+
return
+
}
+
+
// Get community from AppView DB
+
community, err := h.service.GetCommunity(r.Context(), communityID)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return community data
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(community)
+
}
+74
internal/api/handlers/community/list.go
···
+
package community
+
+
import (
+
"encoding/json"
+
"net/http"
+
"strconv"
+
+
"Coves/internal/core/communities"
+
)
+
+
// ListHandler handles listing communities
+
type ListHandler struct {
+
service communities.Service
+
}
+
+
// NewListHandler creates a new list handler
+
func NewListHandler(service communities.Service) *ListHandler {
+
return &ListHandler{
+
service: service,
+
}
+
}
+
+
// HandleList lists communities with filters
+
// GET /xrpc/social.coves.community.list?limit={n}&cursor={offset}&visibility={public|unlisted}&sortBy={created_at|member_count}
+
func (h *ListHandler) HandleList(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodGet {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse query parameters
+
query := r.URL.Query()
+
+
limit := 50
+
if limitStr := query.Get("limit"); limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+
limit = l
+
}
+
}
+
+
offset := 0
+
if cursorStr := query.Get("cursor"); cursorStr != "" {
+
if o, err := strconv.Atoi(cursorStr); err == nil && o >= 0 {
+
offset = o
+
}
+
}
+
+
req := communities.ListCommunitiesRequest{
+
Limit: limit,
+
Offset: offset,
+
Visibility: query.Get("visibility"),
+
HostedBy: query.Get("hostedBy"),
+
SortBy: query.Get("sortBy"),
+
SortOrder: query.Get("sortOrder"),
+
}
+
+
// Get communities from AppView DB
+
results, total, err := h.service.ListCommunities(r.Context(), req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Build response
+
response := map[string]interface{}{
+
"communities": results,
+
"cursor": offset + len(results),
+
"total": total,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+78
internal/api/handlers/community/search.go
···
+
package community
+
+
import (
+
"encoding/json"
+
"net/http"
+
"strconv"
+
+
"Coves/internal/core/communities"
+
)
+
+
// SearchHandler handles community search
+
type SearchHandler struct {
+
service communities.Service
+
}
+
+
// NewSearchHandler creates a new search handler
+
func NewSearchHandler(service communities.Service) *SearchHandler {
+
return &SearchHandler{
+
service: service,
+
}
+
}
+
+
// HandleSearch searches communities by name/description
+
// GET /xrpc/social.coves.community.search?q={query}&limit={n}&cursor={offset}
+
func (h *SearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodGet {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse query parameters
+
query := r.URL.Query()
+
+
searchQuery := query.Get("q")
+
if searchQuery == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "q parameter is required")
+
return
+
}
+
+
limit := 50
+
if limitStr := query.Get("limit"); limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+
limit = l
+
}
+
}
+
+
offset := 0
+
if cursorStr := query.Get("cursor"); cursorStr != "" {
+
if o, err := strconv.Atoi(cursorStr); err == nil && o >= 0 {
+
offset = o
+
}
+
}
+
+
req := communities.SearchCommunitiesRequest{
+
Query: searchQuery,
+
Limit: limit,
+
Offset: offset,
+
Visibility: query.Get("visibility"),
+
}
+
+
// Search communities in AppView DB
+
results, total, err := h.service.SearchCommunities(r.Context(), req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Build response
+
response := map[string]interface{}{
+
"communities": results,
+
"cursor": offset + len(results),
+
"total": total,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+124
internal/api/handlers/community/subscribe.go
···
+
package community
+
+
import (
+
"encoding/json"
+
"net/http"
+
+
"Coves/internal/core/communities"
+
)
+
+
// SubscribeHandler handles community subscriptions
+
type SubscribeHandler struct {
+
service communities.Service
+
}
+
+
// NewSubscribeHandler creates a new subscribe handler
+
func NewSubscribeHandler(service communities.Service) *SubscribeHandler {
+
return &SubscribeHandler{
+
service: service,
+
}
+
}
+
+
// HandleSubscribe subscribes a user to a community
+
// POST /xrpc/social.coves.community.subscribe
+
// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
+
func (h *SubscribeHandler) HandleSubscribe(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var req struct {
+
Community string `json:"community"`
+
}
+
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
if req.Community == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// TODO(Communities-OAuth): Extract authenticated user DID from request context
+
// This MUST be replaced with OAuth middleware before production deployment
+
// Expected implementation:
+
// userDID := r.Context().Value("authenticated_user_did").(string)
+
// For now, we read from header (INSECURE - allows impersonation)
+
userDID := r.Header.Get("X-User-DID")
+
if userDID == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Subscribe via service (write-forward to PDS)
+
subscription, err := h.service.SubscribeToCommunity(r.Context(), userDID, req.Community)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response
+
response := map[string]interface{}{
+
"uri": subscription.RecordURI,
+
"cid": subscription.RecordCID,
+
"existing": false, // Would be true if already subscribed
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+
+
// HandleUnsubscribe unsubscribes a user from a community
+
// POST /xrpc/social.coves.community.unsubscribe
+
// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
+
func (h *SubscribeHandler) HandleUnsubscribe(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// Parse request body
+
var req struct {
+
Community string `json:"community"`
+
}
+
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
if req.Community == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// TODO(Communities-OAuth): Extract authenticated user DID from request context
+
// This MUST be replaced with OAuth middleware before production deployment
+
// Expected implementation:
+
// userDID := r.Context().Value("authenticated_user_did").(string)
+
// For now, we read from header (INSECURE - allows impersonation)
+
userDID := r.Header.Get("X-User-DID")
+
if userDID == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// Unsubscribe via service (delete record on PDS)
+
err := h.service.UnsubscribeFromCommunity(r.Context(), userDID, req.Community)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// Return success response
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(map[string]interface{}{
+
"success": true,
+
})
+
}
+43
internal/api/routes/community.go
···
+
package routes
+
+
import (
+
"github.com/go-chi/chi/v5"
+
+
"Coves/internal/api/handlers/community"
+
"Coves/internal/core/communities"
+
)
+
+
// RegisterCommunityRoutes registers community-related XRPC endpoints on the router
+
// Implements social.coves.community.* lexicon endpoints
+
func RegisterCommunityRoutes(r chi.Router, service communities.Service) {
+
// Initialize handlers
+
createHandler := community.NewCreateHandler(service)
+
getHandler := community.NewGetHandler(service)
+
listHandler := community.NewListHandler(service)
+
searchHandler := community.NewSearchHandler(service)
+
subscribeHandler := community.NewSubscribeHandler(service)
+
+
// Query endpoints (GET)
+
// social.coves.community.get - get a single community by identifier
+
r.Get("/xrpc/social.coves.community.get", getHandler.HandleGet)
+
+
// social.coves.community.list - list communities with filters
+
r.Get("/xrpc/social.coves.community.list", listHandler.HandleList)
+
+
// social.coves.community.search - search communities
+
r.Get("/xrpc/social.coves.community.search", searchHandler.HandleSearch)
+
+
// Procedure endpoints (POST) - write-forward operations
+
// social.coves.community.create - create a new community
+
r.Post("/xrpc/social.coves.community.create", createHandler.HandleCreate)
+
+
// social.coves.community.subscribe - subscribe to a community
+
r.Post("/xrpc/social.coves.community.subscribe", subscribeHandler.HandleSubscribe)
+
+
// social.coves.community.unsubscribe - unsubscribe from a community
+
r.Post("/xrpc/social.coves.community.unsubscribe", subscribeHandler.HandleUnsubscribe)
+
+
// TODO: Add update and delete handlers when implemented
+
// r.Post("/xrpc/social.coves.community.update", updateHandler.HandleUpdate)
+
// r.Post("/xrpc/social.coves.community.delete", deleteHandler.HandleDelete)
+
}