A community based topic aggregation platform built on atproto

feat(posts): add XRPC handler and routes

Add POST /xrpc/social.coves.post.create endpoint:
- Authentication required via OAuth middleware
- Validate request body size (1MB limit for DoS prevention)
- Validate at-identifier format for community field
- Reject client-provided authorDid (security)
- Set authorDid from authenticated JWT
- Error mapping for lexicon-compliant responses

Handler security features:
- Body size limit prevents DoS
- Author impersonation prevented
- All 4 at-identifier formats supported
- Proper error codes (400, 401, 403, 404, 500)

Route registration integrates with auth middleware.

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

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

Changed files
+189
internal
api
handlers
routes
+106
internal/api/handlers/post/create.go
···
···
+
package post
+
+
import (
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/posts"
+
"encoding/json"
+
"log"
+
"net/http"
+
"strings"
+
)
+
+
// CreateHandler handles post creation requests
+
type CreateHandler struct {
+
service posts.Service
+
}
+
+
// NewCreateHandler creates a new create handler
+
func NewCreateHandler(service posts.Service) *CreateHandler {
+
return &CreateHandler{
+
service: service,
+
}
+
}
+
+
// HandleCreate handles POST /xrpc/social.coves.post.create
+
// Creates a new post in a community's repository
+
func (h *CreateHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
+
// 1. Check HTTP method
+
if r.Method != http.MethodPost {
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+
return
+
}
+
+
// 2. Limit request body size to prevent DoS attacks
+
// 1MB allows for large content + embeds while preventing abuse
+
r.Body = http.MaxBytesReader(w, r.Body, 1*1024*1024)
+
+
// 3. Parse request body
+
var req posts.CreatePostRequest
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+
// Check if error is due to body size limit
+
if err.Error() == "http: request body too large" {
+
writeError(w, http.StatusRequestEntityTooLarge, "RequestTooLarge",
+
"Request body too large (max 1MB)")
+
return
+
}
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
+
return
+
}
+
+
// 4. Extract authenticated user DID from request context (injected by auth middleware)
+
userDID := middleware.GetUserDID(r)
+
if userDID == "" {
+
writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
+
return
+
}
+
+
// 5. Validate required fields
+
if req.Community == "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
+
return
+
}
+
+
// 5b. Basic format validation for better UX (fail fast on obviously invalid input)
+
// Valid formats accepted by resolver:
+
// - DID: did:plc:xyz, did:web:example.com
+
// - Scoped handle: !name@instance
+
// - Canonical handle: name.community.instance
+
// - @-prefixed handle: @name.community.instance
+
//
+
// We only reject obviously invalid formats here (no prefix, no dots, no @ for !)
+
// The service layer (ResolveCommunityIdentifier) does comprehensive validation
+
+
// Scoped handles must include @ symbol
+
if strings.HasPrefix(req.Community, "!") && !strings.Contains(req.Community, "@") {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"scoped handle must include @ symbol (!name@instance)")
+
return
+
}
+
+
// 6. SECURITY: Reject client-provided authorDid
+
// This prevents users from impersonating other users
+
if req.AuthorDID != "" {
+
writeError(w, http.StatusBadRequest, "InvalidRequest",
+
"authorDid must not be provided - derived from authenticated user")
+
return
+
}
+
+
// 7. Set author from authenticated user context
+
req.AuthorDID = userDID
+
+
// 8. Call service to create post (write-forward to PDS)
+
// Note: Service layer will resolve community at-identifier (handle or DID) to DID
+
response, err := h.service.CreatePost(r.Context(), req)
+
if err != nil {
+
handleServiceError(w, err)
+
return
+
}
+
+
// 9. Return success response matching lexicon output
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
if err := json.NewEncoder(w).Encode(response); err != nil {
+
// Log encoding errors but don't return error response (headers already sent)
+
log.Printf("Failed to encode post creation response: %v", err)
+
}
+
}
+57
internal/api/handlers/post/errors.go
···
···
+
package post
+
+
import (
+
"Coves/internal/core/posts"
+
"encoding/json"
+
"log"
+
"net/http"
+
)
+
+
type errorResponse struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
// writeError writes a JSON error response
+
func writeError(w http.ResponseWriter, statusCode int, errorType, message string) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(statusCode)
+
if err := json.NewEncoder(w).Encode(errorResponse{
+
Error: errorType,
+
Message: message,
+
}); err != nil {
+
log.Printf("Failed to encode error response: %v", err)
+
}
+
}
+
+
// handleServiceError maps service errors to HTTP responses
+
func handleServiceError(w http.ResponseWriter, err error) {
+
switch {
+
case err == posts.ErrCommunityNotFound:
+
writeError(w, http.StatusNotFound, "CommunityNotFound",
+
"Community not found")
+
+
case err == posts.ErrNotAuthorized:
+
writeError(w, http.StatusForbidden, "NotAuthorized",
+
"You are not authorized to post in this community")
+
+
case err == posts.ErrBanned:
+
writeError(w, http.StatusForbidden, "Banned",
+
"You are banned from this community")
+
+
case posts.IsContentRuleViolation(err):
+
writeError(w, http.StatusBadRequest, "ContentRuleViolation", err.Error())
+
+
case posts.IsValidationError(err):
+
writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
+
+
case posts.IsNotFound(err):
+
writeError(w, http.StatusNotFound, "NotFound", err.Error())
+
+
default:
+
// Don't leak internal error details to clients
+
log.Printf("Unexpected error in post handler: %v", err)
+
writeError(w, http.StatusInternalServerError, "InternalServerError",
+
"An internal error occurred")
+
}
+
}
+26
internal/api/routes/post.go
···
···
+
package routes
+
+
import (
+
"Coves/internal/api/handlers/post"
+
"Coves/internal/api/middleware"
+
"Coves/internal/core/posts"
+
+
"github.com/go-chi/chi/v5"
+
)
+
+
// RegisterPostRoutes registers post-related XRPC endpoints on the router
+
// Implements social.coves.post.* lexicon endpoints
+
func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {
+
// Initialize handlers
+
createHandler := post.NewCreateHandler(service)
+
+
// Procedure endpoints (POST) - require authentication
+
// social.coves.post.create - create a new post in a community
+
r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.create", createHandler.HandleCreate)
+
+
// Future endpoints (Beta):
+
// r.Get("/xrpc/social.coves.post.get", getHandler.HandleGet)
+
// r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.update", updateHandler.HandleUpdate)
+
// r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.delete", deleteHandler.HandleDelete)
+
// r.Get("/xrpc/social.coves.post.list", listHandler.HandleList)
+
}