A community based topic aggregation platform built on atproto

fix(votes): fix error handling and add subject validation

P1 fixes:
- Use errors.Is() in handler to match wrapped errors (ErrNotAuthorized
was returning 500 instead of 403 when wrapped)

P2 fixes:
- Add SubjectValidator interface to check post/comment existence
- Service now validates subject exists before creating vote
- Returns ErrSubjectNotFound per lexicon if subject doesn't exist
- Prevents dangling votes on non-existent content

Also:
- Add CompositeSubjectValidator for checking both posts and comments
- Wire up subject validation in main.go with post/comment repos

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

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

Changed files
+130 -25
cmd
server
internal
api
handlers
vote
core
tests
integration
+30 -4
cmd/server/main.go
···
voteRepo := postgresRepo.NewVoteRepository(db)
log.Println("✅ Vote repository initialized (Jetstream indexing only)")
-
// Initialize vote service (for XRPC API endpoints)
-
voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil)
-
log.Println("✅ Vote service initialized (with OAuth authentication)")
-
// Initialize comment repository (used by Jetstream consumer for indexing)
commentRepo := postgresRepo.NewCommentRepository(db)
log.Println("✅ Comment repository initialized (Jetstream indexing only)")
// Initialize comment service (for query API)
// Requires user and community repos for proper author/community hydration per lexicon
···
voteRepo := postgresRepo.NewVoteRepository(db)
log.Println("✅ Vote repository initialized (Jetstream indexing only)")
// Initialize comment repository (used by Jetstream consumer for indexing)
commentRepo := postgresRepo.NewCommentRepository(db)
log.Println("✅ Comment repository initialized (Jetstream indexing only)")
+
+
// Initialize subject validator for votes (checks posts and comments exist)
+
subjectValidator := votes.NewCompositeSubjectValidator(
+
// Post existence checker
+
func(ctx context.Context, uri string) (bool, error) {
+
_, err := postRepo.GetByURI(ctx, uri)
+
if err != nil {
+
if err == posts.ErrNotFound {
+
return false, nil
+
}
+
return false, err
+
}
+
return true, nil
+
},
+
// Comment existence checker
+
func(ctx context.Context, uri string) (bool, error) {
+
_, err := commentRepo.GetByURI(ctx, uri)
+
if err != nil {
+
if err == comments.ErrCommentNotFound {
+
return false, nil
+
}
+
return false, err
+
}
+
return true, nil
+
},
+
)
+
+
// Initialize vote service (for XRPC API endpoints)
+
voteService := votes.NewService(voteRepo, subjectValidator, oauthClient, oauthStore, nil)
+
log.Println("✅ Vote service initialized (with OAuth authentication and subject validation)")
// Initialize comment service (for query API)
// Requires user and community repos for proper author/community hydration per lexicon
+10 -8
internal/api/handlers/vote/errors.go
···
import (
"Coves/internal/core/votes"
"encoding/json"
"log"
"net/http"
)
···
// handleServiceError converts service errors to appropriate HTTP responses
// Error names MUST match lexicon definitions exactly (UpperCamelCase)
func handleServiceError(w http.ResponseWriter, err error) {
-
switch err {
-
case votes.ErrVoteNotFound:
// Matches: social.coves.feed.vote.delete#VoteNotFound
writeError(w, http.StatusNotFound, "VoteNotFound", "No vote found for this subject")
-
case votes.ErrSubjectNotFound:
// Matches: social.coves.feed.vote.create#SubjectNotFound
writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found")
-
case votes.ErrInvalidDirection:
writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'")
-
case votes.ErrInvalidSubject:
// Matches: social.coves.feed.vote.create#InvalidSubject
writeError(w, http.StatusBadRequest, "InvalidSubject", "The subject reference is invalid or malformed")
-
case votes.ErrVoteAlreadyExists:
writeError(w, http.StatusConflict, "AlreadyExists", "Vote already exists")
-
case votes.ErrNotAuthorized:
// Matches: social.coves.feed.vote.create#NotAuthorized, social.coves.feed.vote.delete#NotAuthorized
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to vote on this content")
-
case votes.ErrBanned:
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to vote on this content")
default:
// Internal server error - log the actual error for debugging
···
import (
"Coves/internal/core/votes"
"encoding/json"
+
"errors"
"log"
"net/http"
)
···
// handleServiceError converts service errors to appropriate HTTP responses
// Error names MUST match lexicon definitions exactly (UpperCamelCase)
+
// Uses errors.Is() to handle wrapped errors correctly
func handleServiceError(w http.ResponseWriter, err error) {
+
switch {
+
case errors.Is(err, votes.ErrVoteNotFound):
// Matches: social.coves.feed.vote.delete#VoteNotFound
writeError(w, http.StatusNotFound, "VoteNotFound", "No vote found for this subject")
+
case errors.Is(err, votes.ErrSubjectNotFound):
// Matches: social.coves.feed.vote.create#SubjectNotFound
writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found")
+
case errors.Is(err, votes.ErrInvalidDirection):
writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'")
+
case errors.Is(err, votes.ErrInvalidSubject):
// Matches: social.coves.feed.vote.create#InvalidSubject
writeError(w, http.StatusBadRequest, "InvalidSubject", "The subject reference is invalid or malformed")
+
case errors.Is(err, votes.ErrVoteAlreadyExists):
writeError(w, http.StatusConflict, "AlreadyExists", "Vote already exists")
+
case errors.Is(err, votes.ErrNotAuthorized):
// Matches: social.coves.feed.vote.create#NotAuthorized, social.coves.feed.vote.delete#NotAuthorized
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to vote on this content")
+
case errors.Is(err, votes.ErrBanned):
writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to vote on this content")
default:
// Internal server error - log the actual error for debugging
+27 -9
internal/core/votes/service_impl.go
···
// voteService implements the Service interface for vote operations
type voteService struct {
-
repo Repository
-
oauthClient *oauthclient.OAuthClient
-
oauthStore oauth.ClientAuthStore
-
logger *slog.Logger
}
// NewService creates a new vote service instance
-
func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service {
if logger == nil {
logger = slog.Default()
}
return &voteService{
-
repo: repo,
-
oauthClient: oauthClient,
-
oauthStore: oauthStore,
-
logger: logger,
}
}
···
// Validate subject CID is provided
if req.Subject.CID == "" {
return nil, ErrInvalidSubject
}
// Check for existing vote by querying PDS directly (source of truth)
···
// voteService implements the Service interface for vote operations
type voteService struct {
+
repo Repository
+
subjectValidator SubjectValidator
+
oauthClient *oauthclient.OAuthClient
+
oauthStore oauth.ClientAuthStore
+
logger *slog.Logger
}
// NewService creates a new vote service instance
+
// subjectValidator can be nil to skip subject existence checks (not recommended for production)
+
func NewService(repo Repository, subjectValidator SubjectValidator, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service {
if logger == nil {
logger = slog.Default()
}
return &voteService{
+
repo: repo,
+
subjectValidator: subjectValidator,
+
oauthClient: oauthClient,
+
oauthStore: oauthStore,
+
logger: logger,
}
}
···
// Validate subject CID is provided
if req.Subject.CID == "" {
return nil, ErrInvalidSubject
+
}
+
+
// Validate subject exists in AppView (post or comment)
+
// This prevents creating votes on non-existent content
+
if s.subjectValidator != nil {
+
exists, err := s.subjectValidator.SubjectExists(ctx, req.Subject.URI)
+
if err != nil {
+
s.logger.Error("failed to validate subject existence",
+
"error", err,
+
"subject", req.Subject.URI)
+
return nil, fmt.Errorf("failed to validate subject: %w", err)
+
}
+
if !exists {
+
return nil, ErrSubjectNotFound
+
}
}
// Check for existing vote by querying PDS directly (source of truth)
+50
internal/core/votes/subject_validator.go
···
···
+
package votes
+
+
import (
+
"context"
+
"strings"
+
)
+
+
// SubjectExistsFunc is a function type that checks if a subject exists
+
type SubjectExistsFunc func(ctx context.Context, uri string) (bool, error)
+
+
// CompositeSubjectValidator validates subjects by checking both posts and comments
+
type CompositeSubjectValidator struct {
+
postExists SubjectExistsFunc
+
commentExists SubjectExistsFunc
+
}
+
+
// NewCompositeSubjectValidator creates a validator that checks both posts and comments
+
// Pass nil for either function to skip validation for that type
+
func NewCompositeSubjectValidator(postExists, commentExists SubjectExistsFunc) *CompositeSubjectValidator {
+
return &CompositeSubjectValidator{
+
postExists: postExists,
+
commentExists: commentExists,
+
}
+
}
+
+
// SubjectExists checks if a post or comment exists at the given URI
+
// Determines type from the collection in the URI (e.g., social.coves.feed.post vs social.coves.feed.comment)
+
func (v *CompositeSubjectValidator) SubjectExists(ctx context.Context, uri string) (bool, error) {
+
// Parse collection from AT-URI: at://did/collection/rkey
+
// Example: at://did:plc:xxx/social.coves.feed.post/abc123
+
if strings.Contains(uri, "/social.coves.feed.post/") {
+
if v.postExists != nil {
+
return v.postExists(ctx, uri)
+
}
+
// If no post checker, assume exists (for testing)
+
return true, nil
+
}
+
+
if strings.Contains(uri, "/social.coves.feed.comment/") {
+
if v.commentExists != nil {
+
return v.commentExists(ctx, uri)
+
}
+
// If no comment checker, assume exists (for testing)
+
return true, nil
+
}
+
+
// Unknown collection type - could be from another app
+
// For now, allow voting on unknown types (future-proofing)
+
return true, nil
+
}
+9
internal/core/votes/vote.go
···
package votes
import (
"time"
)
// Vote represents a vote in the AppView database
// Votes are indexed from the firehose after being written to user repositories
···
package votes
import (
+
"context"
"time"
)
+
+
// SubjectValidator validates that vote subjects (posts/comments) exist
+
// This prevents creating votes on non-existent content
+
type SubjectValidator interface {
+
// SubjectExists checks if a post or comment exists at the given URI
+
// Returns true if found, false if not found
+
SubjectExists(ctx context.Context, uri string) (bool, error)
+
}
// Vote represents a vote in the AppView database
// Votes are indexed from the firehose after being written to user repositories
+4 -4
tests/integration/vote_e2e_test.go
···
oauthClient := SetupOAuthTestClient(t, oauthStore)
// Setup services
-
voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil)
// Create test user on PDS
testUserHandle := fmt.Sprintf("voter-%d.local.coves.dev", time.Now().Unix())
···
oauthStore := SetupOAuthTestStore(t, db)
oauthClient := SetupOAuthTestClient(t, oauthStore)
-
voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil)
// Create test user
testUserHandle := fmt.Sprintf("toggle-%d.local.coves.dev", time.Now().Unix())
···
oauthStore := SetupOAuthTestStore(t, db)
oauthClient := SetupOAuthTestClient(t, oauthStore)
-
voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil)
// Create test user
testUserHandle := fmt.Sprintf("flip-%d.local.coves.dev", time.Now().Unix())
···
oauthStore := SetupOAuthTestStore(t, db)
oauthClient := SetupOAuthTestClient(t, oauthStore)
-
voteService := votes.NewService(voteRepo, oauthClient, oauthStore, nil)
// Create test user
testUserHandle := fmt.Sprintf("delete-%d.local.coves.dev", time.Now().Unix())
···
oauthClient := SetupOAuthTestClient(t, oauthStore)
// Setup services
+
voteService := votes.NewService(voteRepo, nil, oauthClient, oauthStore, nil)
// Create test user on PDS
testUserHandle := fmt.Sprintf("voter-%d.local.coves.dev", time.Now().Unix())
···
oauthStore := SetupOAuthTestStore(t, db)
oauthClient := SetupOAuthTestClient(t, oauthStore)
+
voteService := votes.NewService(voteRepo, nil, oauthClient, oauthStore, nil)
// Create test user
testUserHandle := fmt.Sprintf("toggle-%d.local.coves.dev", time.Now().Unix())
···
oauthStore := SetupOAuthTestStore(t, db)
oauthClient := SetupOAuthTestClient(t, oauthStore)
+
voteService := votes.NewService(voteRepo, nil, oauthClient, oauthStore, nil)
// Create test user
testUserHandle := fmt.Sprintf("flip-%d.local.coves.dev", time.Now().Unix())
···
oauthStore := SetupOAuthTestStore(t, db)
oauthClient := SetupOAuthTestClient(t, oauthStore)
+
voteService := votes.NewService(voteRepo, nil, oauthClient, oauthStore, nil)
// Create test user
testUserHandle := fmt.Sprintf("delete-%d.local.coves.dev", time.Now().Unix())