+115
internal/api/handlers/vote/create_vote.go
+115
internal/api/handlers/vote/create_vote.go
···
+93
internal/api/handlers/vote/delete_vote.go
+93
internal/api/handlers/vote/delete_vote.go
···
+24
internal/api/routes/vote.go
+24
internal/api/routes/vote.go
···+func RegisterVoteRoutes(r chi.Router, voteService votes.Service, authMiddleware *middleware.OAuthAuthMiddleware) {+r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.feed.vote.create", createHandler.HandleCreateVote)+r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.feed.vote.delete", deleteHandler.HandleDeleteVote)
-3
internal/api/handlers/vote/errors.go
-3
internal/api/handlers/vote/errors.go
···-writeError(w, http.StatusNotFound, "SubjectNotFound", "The subject post or comment was not found")writeError(w, http.StatusBadRequest, "InvalidRequest", "Vote direction must be 'up' or 'down'")
+4
-4
internal/atproto/oauth/handlers_security.go
+4
-4
internal/atproto/oauth/handlers_security.go
···
-3
internal/core/votes/errors.go
-3
internal/core/votes/errors.go
···
+3
-2
internal/db/postgres/vote_repo.go
+3
-2
internal/db/postgres/vote_repo.go
······
+285
internal/atproto/oauth/dev_auth_resolver.go
+285
internal/atproto/oauth/dev_auth_resolver.go
···+// The standard indigo OAuth resolver requires HTTPS and no port numbers, which breaks local testing.+func (r *DevAuthResolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) {+func (r *DevAuthResolver) ResolveAuthServerMetadataDev(ctx context.Context, serverURL string) (*oauthlib.AuthServerMetadata, error) {+metaURL = fmt.Sprintf("%s://%s:%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname(), u.Port())+metaURL = fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname())+func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {+info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier)
+106
internal/atproto/oauth/dev_resolver.go
+106
internal/atproto/oauth/dev_resolver.go
···+func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {+// ResolveIdentifier attempts to resolve a handle to DID, or returns the DID if already provided+func (r *DevHandleResolver) ResolveIdentifier(ctx context.Context, identifier string) (string, error) {
+41
internal/atproto/oauth/dev_stubs.go
+41
internal/atproto/oauth/dev_stubs.go
···+func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {+func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
+5
-1
scripts/dev-run.sh
+5
-1
scripts/dev-run.sh
······
+267
cmd/reindex-votes/main.go
+267
cmd/reindex-votes/main.go
···+if _, err := db.ExecContext(ctx, "UPDATE posts SET upvote_count = 0, downvote_count = 0, score = 0"); err != nil {+if _, err := db.ExecContext(ctx, "UPDATE comments SET upvote_count = 0, downvote_count = 0, score = 0"); err != nil {+INSERT INTO votes (uri, cid, rkey, voter_did, subject_uri, subject_cid, direction, created_at, indexed_at)+updateQuery = `UPDATE posts SET upvote_count = upvote_count + 1, score = upvote_count + 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`+updateQuery = `UPDATE posts SET downvote_count = downvote_count + 1, score = upvote_count - (downvote_count + 1) WHERE uri = $1 AND deleted_at IS NULL`+updateQuery = `UPDATE comments SET upvote_count = upvote_count + 1, score = upvote_count + 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`+updateQuery = `UPDATE comments SET downvote_count = downvote_count + 1, score = upvote_count - (downvote_count + 1) WHERE uri = $1 AND deleted_at IS NULL`
+221
internal/core/votes/cache.go
+221
internal/core/votes/cache.go
···+func (c *VoteCache) fetchAllVotesFromPDS(ctx context.Context, pdsClient pds.Client) (map[string]*CachedVote, error) {
+14
internal/core/votes/service.go
+14
internal/core/votes/service.go
···DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req DeleteVoteRequest) error+// Returns from cache if available, otherwise returns nil (caller should ensure cache is populated).
+84
-2
internal/core/votes/service_impl.go
+84
-2
internal/core/votes/service_impl.go
···-func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service {+func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, cache *VoteCache, logger *slog.Logger) Service {···-func NewServiceWithPDSFactory(repo Repository, logger *slog.Logger, factory PDSClientFactory) Service {+func NewServiceWithPDSFactory(repo Repository, cache *VoteCache, logger *slog.Logger, factory PDSClientFactory) Service {············+func (s *voteService) EnsureCachePopulated(ctx context.Context, session *oauth.ClientSessionData) error {+func (s *voteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*CachedVote {
+76
-16
internal/atproto/jetstream/vote_consumer.go
+76
-16
internal/atproto/jetstream/vote_consumer.go
······-func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) error {+// Returns (true, nil) if vote was newly inserted, (false, nil) if already existed (idempotent)+func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) (bool, error) {···+if err := tx.QueryRowContext(ctx, checkQuery, vote.VoterDID, vote.SubjectURI, vote.URI).Scan(&existingDirection); err != nil && err != sql.ErrNoRows {+if _, err := tx.ExecContext(ctx, softDeleteQuery, vote.VoterDID, vote.SubjectURI, vote.URI); err != nil {+decrementQuery = `UPDATE posts SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE comments SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE posts SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE comments SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`+log.Printf("Cleaned up stale vote for %s on %s (was %s)", vote.VoterDID, vote.SubjectURI, existingDirection.String)······log.Printf("Vote subject has unsupported collection: %s (vote indexed, counts not updated)", collection)···
+38
internal/core/comments/types.go
+38
internal/core/comments/types.go
···
+130
internal/api/handlers/comments/create_comment.go
+130
internal/api/handlers/comments/create_comment.go
···+// CreateCommentInput matches the lexicon input schema for social.coves.community.comment.create
+80
internal/api/handlers/comments/delete_comment.go
+80
internal/api/handlers/comments/delete_comment.go
···+// DeleteCommentInput matches the lexicon input schema for social.coves.community.comment.delete
+34
-2
internal/api/handlers/comments/errors.go
+34
-2
internal/api/handlers/comments/errors.go
······+writeError(w, http.StatusBadRequest, "InvalidReply", "The reply reference is invalid or malformed")+writeError(w, http.StatusBadRequest, "ContentTooLong", "Comment content exceeds 10000 graphemes")+writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to perform this action")
+112
internal/api/handlers/comments/update_comment.go
+112
internal/api/handlers/comments/update_comment.go
···+// UpdateCommentInput matches the lexicon input schema for social.coves.community.comment.update
+35
internal/api/routes/comment.go
+35
internal/api/routes/comment.go
···+func RegisterCommentRoutes(r chi.Router, service commentsCore.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+4
-2
tests/integration/comment_query_test.go
+4
-2
tests/integration/comment_query_test.go
···+// Use factory constructor with nil factory - these tests only use the read path (GetComments)+return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+// Use factory constructor with nil factory - these tests only use the read path (GetComments)+service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
+6
-3
tests/integration/comment_vote_test.go
+6
-3
tests/integration/comment_vote_test.go
···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
+1
-1
go.mod
+1
-1
go.mod
···
+2
go.sum
+2
go.sum
···github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+66
internal/db/migrations/021_add_comment_deletion_metadata.sql
+66
internal/db/migrations/021_add_comment_deletion_metadata.sql
···+COMMENT ON COLUMN comments.deletion_reason IS 'Reason for deletion: author (user deleted), moderator (community mod removed)';+CREATE INDEX idx_comments_root ON comments(root_uri, created_at DESC) WHERE deleted_at IS NULL;+CREATE INDEX idx_comments_parent ON comments(parent_uri, created_at DESC) WHERE deleted_at IS NULL;+CREATE INDEX idx_comments_parent_score ON comments(parent_uri, score DESC, created_at DESC) WHERE deleted_at IS NULL;
+5
internal/core/communityFeeds/types.go
+5
internal/core/communityFeeds/types.go
···
+5
internal/core/discover/types.go
+5
internal/core/discover/types.go
···
+5
internal/core/timeline/types.go
+5
internal/core/timeline/types.go
···
+12
internal/api/handlers/vote/create_vote_test.go
+12
internal/api/handlers/vote/create_vote_test.go
···+func (m *mockVoteService) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error {+func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote {
+2
scripts/generate_test_comments.go
+2
scripts/generate_test_comments.go
+29
internal/atproto/oauth/store.go
+29
internal/atproto/oauth/store.go
···+func (s *PostgresOAuthStore) UpdateHandleByDID(ctx context.Context, did, newHandle string) (int64, error) {
+375
tests/integration/oauth_session_handle_sync_test.go
+375
tests/integration/oauth_session_handle_sync_test.go
···+// TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \+// TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \+t.Skip("Jetstream not available at localhost:6008 - run 'docker-compose --profile jetstream up -d' first")
+76
-1
aggregators/kagi-news/tests/test_e2e.py
+76
-1
aggregators/kagi-news/tests/test_e2e.py
···
+137
aggregators/kagi-news/tests/test_main.py
+137
aggregators/kagi-news/tests/test_main.py
···+def test_create_post_with_sources_in_embed(self, mock_config, mock_rss_feed, sample_story, tmp_path):
+1188
tests/integration/comment_e2e_test.go
+1188
tests/integration/comment_e2e_test.go
···+jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)+t.Skipf("Jetstream not running at %s: %v. Run 'docker-compose --profile jetstream up' to start.", jetstreamURL, err)+pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)+testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-e2e-community", "owner.test")+postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Comments", 0, time.Now())+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)+t.Errorf("Expected collection social.coves.community.comment, got %s", event.Commit.Collection)+jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)+pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)+testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-upd-community", "owner.test")+postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Update", 0, time.Now())+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)+subscribeErr := subscribeToJetstreamForCommentUpdate(ctx, jetstreamURL, userDID, commentConsumer, updateEventChan, updateDone)+pdsResp, httpErr := http.Get(fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=social.coves.community.comment&rkey=%s",+t.Fatalf("Failed to get record from PDS: status=%d body=%s", pdsResp.StatusCode, string(body))+jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.community.comment", pdsHostname)+pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)+testCommunityDID, err := createFeedTestCommunity(db, ctx, "comment-del-community", "owner.test")+postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Test Post for Delete", 0, time.Now())+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+subscribeErr := subscribeToJetstreamForComment(ctx, jetstreamURL, userDID, commentConsumer, eventChan, done)+subscribeErr := subscribeToJetstreamForCommentDelete(ctx, jetstreamURL, userDID, commentConsumer, deleteEventChan, deleteDone)+err = commentService.DeleteComment(ctx, session, comments.DeleteCommentRequest{URI: commentResp.URI})+// subscribeToJetstreamForComment subscribes to real Jetstream firehose for comment create events+pdsAccessTokenA, userADID, err := createPDSAccount(pdsURL, userAHandle, userAEmail, userAPassword)+pdsAccessTokenB, userBDID, err := createPDSAccount(pdsURL, userBHandle, userBEmail, userBPassword)+testCommunityDID, err := createFeedTestCommunity(db, ctx, "auth-test-community", "owner.test")+postURI := createTestPost(t, db, testCommunityDID, testUserA.DID, "Auth Test Post", 0, time.Now())+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)+testCommunityDID, err := createFeedTestCommunity(db, ctx, "val-test-community", "owner.test")+postURI := createTestPost(t, db, testCommunityDID, testUser.DID, "Validation Test Post", 0, time.Now())+commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+43
-12
internal/atproto/jetstream/community_consumer.go
+43
-12
internal/atproto/jetstream/community_consumer.go
···+log.Printf("WARNING: Failed to create DID cache (size=1000), verification will be slower: %v", err)+panic(fmt.Sprintf("cannot create LRU cache: primary error=%v, fallback error=%v", err, fallbackErr))···+log.Printf("WARNING: Failed to marshal description facets for community %s: %v (facets will be omitted)", did, marshalErr)······+log.Printf("DEBUG: publicsuffix failed for @-format handle domain %q, using raw domain: %v", domain, err)···+log.Printf("DEBUG: publicsuffix failed for handle %q, using naive fallback: %q (error: %v)", handle, fallbackDomain, err)···+log.Printf("WARNING: constructHandleFromProfile: hostedBy %q is not did:web format, cannot construct handle for community %q",
+5
-6
internal/core/communities/pds_provisioning.go
+5
-6
internal/core/communities/pds_provisioning.go
···-email := fmt.Sprintf("community-%s@community.%s", strings.ToLower(communityName), p.instanceDomain)
+49
internal/db/migrations/022_migrate_community_handles_to_c_prefix.sql
+49
internal/db/migrations/022_migrate_community_handles_to_c_prefix.sql
···+SET handle = 'c-' || SPLIT_PART(handle, '.community.', 1) || '.' || SPLIT_PART(handle, '.community.', 2)+SET pds_email = 'c-' || SUBSTRING(pds_email FROM 11 FOR POSITION('@' IN pds_email) - 11) || '@' || SUBSTRING(pds_email FROM POSITION('@community.' IN pds_email) + 11)
+5
-5
tests/integration/community_consumer_test.go
+5
-5
tests/integration/community_consumer_test.go
···············
+8
-8
tests/integration/community_v2_validation_test.go
+8
-8
tests/integration/community_v2_validation_test.go
·····················
+2
-2
tests/integration/token_refresh_test.go
+2
-2
tests/integration/token_refresh_test.go
······
+2
internal/atproto/oauth/store_test.go
+2
internal/atproto/oauth/store_test.go
···
+5
-5
internal/db/postgres/vote_repo_test.go
+5
-5
internal/db/postgres/vote_repo_test.go
···assert.ErrorIs(t, err, votes.ErrVoteNotFound, "GetByVoterAndSubject should not return deleted votes")
+26
-3
tests/integration/oauth_e2e_test.go
+26
-3
tests/integration/oauth_e2e_test.go
···assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound for expired session")······
+5
-5
tests/integration/vote_e2e_test.go
+5
-5
tests/integration/vote_e2e_test.go
···voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())···voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())···voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())···voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory())···
+13
-6
internal/api/handlers/aggregator/list_for_community.go
+13
-6
internal/api/handlers/aggregator/list_for_community.go
······+func NewListForCommunityHandler(service aggregators.Service, communityService communities.Service) *ListForCommunityHandler {···+communityDID, err := h.communityService.ResolveCommunityIdentifier(r.Context(), communityIdentifier)
+18
-10
internal/api/handlers/communityFeed/get_community.go
+18
-10
internal/api/handlers/communityFeed/get_community.go
···-func NewGetCommunityHandler(service communityFeeds.Service, voteService votes.Service) *GetCommunityHandler {+func NewGetCommunityHandler(service communityFeeds.Service, voteService votes.Service, blueskyService blueskypost.Service) *GetCommunityHandler {+log.Printf("[COMMUNITY-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved")···
+19
-11
internal/api/handlers/timeline/get_timeline.go
+19
-11
internal/api/handlers/timeline/get_timeline.go
···-func NewGetTimelineHandler(service timeline.Service, voteService votes.Service) *GetTimelineHandler {+func NewGetTimelineHandler(service timeline.Service, voteService votes.Service, blueskyService blueskypost.Service) *GetTimelineHandler {+log.Printf("[TIMELINE-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved")···
+178
internal/core/blueskypost/circuit_breaker.go
+178
internal/core/blueskypost/circuit_breaker.go
···+"[BLUESKY-CIRCUIT] Opening circuit for provider '%s' after %d consecutive failures. Last error: %v",
+410
internal/core/blueskypost/circuit_breaker_test.go
+410
internal/core/blueskypost/circuit_breaker_test.go
···+t.Errorf("Expected failure count >= %d after half-open failure, got: %d", cb.failureThreshold, failCount)
+39
internal/core/blueskypost/interfaces.go
+39
internal/core/blueskypost/interfaces.go
···
+131
internal/core/blueskypost/repository.go
+131
internal/core/blueskypost/repository.go
···+func (r *postgresBlueskyPostRepo) Get(ctx context.Context, atURI string) (*BlueskyPostResult, error) {+func (r *postgresBlueskyPostRepo) Set(ctx context.Context, atURI string, result *BlueskyPostResult, ttl time.Duration) error {
+48
internal/core/blueskypost/repository_test.go
+48
internal/core/blueskypost/repository_test.go
···
+101
internal/core/blueskypost/url_parser.go
+101
internal/core/blueskypost/url_parser.go
···+var blueskyPostURLPattern = regexp.MustCompile(`^https://bsky\.app/profile/([^/]+)/post/([^/]+)$`)+func ParseBlueskyURL(ctx context.Context, urlStr string, resolver identity.Resolver) (string, error) {+return "", fmt.Errorf("invalid bsky.app URL format, expected: https://bsky.app/profile/{handle}/post/{rkey}")
+327
internal/core/blueskypost/url_parser_test.go
+327
internal/core/blueskypost/url_parser_test.go
···+func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (string, string, error) {+func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) {+func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) {
+93
internal/core/posts/blob_transform.go
+93
internal/core/posts/blob_transform.go
······+func TransformPostEmbeds(ctx context.Context, postView *PostView, blueskyService blueskypost.Service) {+log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: postView nil=%v, embed nil=%v, blueskyService nil=%v",+log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: embed is not a map (type: %T)", postView.Embed)+log.Printf("[DEBUG] [TRANSFORM-EMBED] Skipping: embed type is not social.coves.embed.post (type: %v)", embedType)
+18
internal/db/migrations/023_create_bluesky_post_cache.sql
+18
internal/db/migrations/023_create_bluesky_post_cache.sql
···+COMMENT ON TABLE bluesky_post_cache IS 'Cache for Bluesky post data fetched from public.api.bsky.app';+COMMENT ON COLUMN bluesky_post_cache.at_uri IS 'AT-URI of the Bluesky post (e.g., at://did:plc:xxx/app.bsky.feed.post/abc123)';+COMMENT ON COLUMN bluesky_post_cache.metadata IS 'Full BlueskyPostResult as JSON (text, author, stats, etc.)';+COMMENT ON COLUMN bluesky_post_cache.expires_at IS 'When this cache entry should be refetched (shorter TTL than unfurl since posts can be edited/deleted)';
-9
.beads/beads.base.jsonl
-9
.beads/beads.base.jsonl
···-{"id":"Coves-8b1","content_hash":"a949ba526ad819badab625c0d5fdbc6a7994d22f059f4a4f7e68635750bd5ea3","title":"Apply functional options pattern to NewGetDiscoverHandler","description":"Location: internal/api/handlers/discover/get.go\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nDepends on: Coves-jdf (NewPostService refactor should be done first to establish pattern)\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.315877238-08:00","updated_at":"2025-12-22T21:35:58.061823373-08:00","source_repo":"."}-{"id":"Coves-8k1","content_hash":"a10053af68636b722a86aa75dd483ece4509d0de4884230beb52453585895589","title":"Refactor service constructors to use functional options pattern","description":"Multiple service constructors have grown to accept many optional dependencies, leading to hard-to-read nil chains:\n```go\nposts.NewPostService(repo, communityService, nil, nil, nil, nil, \"http://localhost:3001\")\n```\n\nApply the functional options pattern to all affected constructors:\n- NewPostService (7 params, 4 optional)\n- NewGetDiscoverHandler (3 params, 2 optional)\n- NewGetCommunityHandler (3 params, 2 optional)\n- NewGetTimelineHandler (3 params, 2 optional)\n- RegisterTimelineRoutes (5 params, 2 optional)\n\nThis will improve readability, make tests self-documenting, and prevent breakage when adding new optional params.\n\nScope: ~20 files, ~50 call sites\nRisk: Low (purely mechanical, no logic changes)","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-22T21:35:19.91257167-08:00","updated_at":"2025-12-22T21:35:39.69736147-08:00","source_repo":"."}-{"id":"Coves-95q","content_hash":"8ec99d598f067780436b985f9ad57f0fa19632026981038df4f65f192186620b","title":"Add comprehensive API documentation","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-17T20:30:34.835721854-08:00","updated_at":"2025-11-17T20:30:34.835721854-08:00","source_repo":".","dependencies":[{"issue_id":"Coves-95q","depends_on_id":"Coves-e16","type":"blocks","created_at":"2025-11-17T20:30:46.273899399-08:00","created_by":"daemon"}]}-{"id":"Coves-e16","content_hash":"7c5d0fc8f0e7f626be3dad62af0e8412467330bad01a244e5a7e52ac5afff1c1","title":"Complete post creation and moderation features","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:12.885991306-08:00","updated_at":"2025-11-17T20:30:12.885991306-08:00","source_repo":"."}-{"id":"Coves-f9q","content_hash":"a1a38759edc37d11227d5992cdbed1b8cf27e09496165e45c542b208f58d34ce","title":"Apply functional options pattern to NewGetTimelineHandler and RegisterTimelineRoutes","description":"Locations:\n- internal/api/handlers/timeline/get.go (NewGetTimelineHandler)\n- internal/api/routes/timeline.go (RegisterTimelineRoutes)\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nUpdate RegisterTimelineRoutes last after handlers are refactored.\n\nDepends on: Coves-jdf, Coves-8b1, Coves-iw5\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.420117481-08:00","updated_at":"2025-12-22T21:35:58.166765845-08:00","source_repo":"."}-{"id":"Coves-fce","content_hash":"26b3e16b99f827316ee0d741cc959464bd0c813446c95aef8105c7fd1e6b09ff","title":"Implement aggregator feed federation","description":"","status":"open","priority":1,"issue_type":"feature","created_at":"2025-11-17T20:30:21.453326012-08:00","updated_at":"2025-11-17T20:30:21.453326012-08:00","source_repo":"."}-{"id":"Coves-iw5","content_hash":"d3379c617b7583f6b88a0523b3cdd1e4415176877ab00b48710819f2484c4856","title":"Apply functional options pattern to NewGetCommunityHandler","description":"Location: internal/api/handlers/communityFeed/get.go\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nDepends on: Coves-jdf (NewPostService refactor should be done first to establish pattern)\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.369297201-08:00","updated_at":"2025-12-22T21:35:58.115771178-08:00","source_repo":"."}-{"id":"Coves-jdf","content_hash":"cb27689d71f44fd555e29d2988f2ad053efb6c565cd4f803ff68eaade59c7546","title":"Apply functional options pattern to NewPostService","description":"Location: internal/core/posts/service.go\n\nCurrent constructor (7 params, 4 optional):\n```go\nfunc NewPostService(repo Repository, communityService communities.Service, aggregatorService aggregators.Service, blobService blobs.Service, unfurlService unfurl.Service, blueskyService blueskypost.Service, pdsURL string) Service\n```\n\nRefactor to:\n```go\ntype Option func(*postService)\n\nfunc WithAggregatorService(svc aggregators.Service) Option\nfunc WithBlobService(svc blobs.Service) Option\nfunc WithUnfurlService(svc unfurl.Service) Option\nfunc WithBlueskyService(svc blueskypost.Service) Option\n\nfunc NewPostService(repo Repository, communityService communities.Service, pdsURL string, opts ...Option) Service\n```\n\nFiles to update:\n- internal/core/posts/service.go (define Option type and With* functions)\n- cmd/server/main.go (production caller)\n- ~15 test files with call sites\n\nStart with this one as it has the most params and is most impacted.\nParent: Coves-8k1","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-22T21:35:27.264325344-08:00","updated_at":"2025-12-22T21:35:58.003863381-08:00","source_repo":"."}-{"id":"Coves-p44","content_hash":"6f12091f6e5f1ad9812f8da4ecd720e0f9df1afd1fdb593b3e52c32be0193d94","title":"Bluesky embed conversion Phase 2: resolve post and populate CID","description":"When converting a Bluesky URL to a social.coves.embed.post, we need to:\n\n1. Call blueskyService.ResolvePost() to get the full post data including CID\n2. Populate both URI and CID in the strongRef\n3. Consider caching/re-using resolved post data for rendering\n\nCurrently disabled in Phase 1 (text-only) because:\n- social.coves.embed.post requires a valid CID in com.atproto.repo.strongRef\n- Empty CID causes PDS to reject the record creation\n\nRelated files:\n- internal/core/posts/service.go:tryConvertBlueskyURLToPostEmbed()\n- internal/atproto/lexicon/social/coves/embed/post.json\n\nThis is part of the Bluesky post cross-posting feature (images/embeds phase).","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-22T21:25:23.540135876-08:00","updated_at":"2025-12-22T21:25:41.704980685-08:00","source_repo":"."}
-1
.beads/beads.base.meta.json
-1
.beads/beads.base.meta.json
···
+6
.beads/.gitignore
+6
.beads/.gitignore
+3
-1
internal/api/routes/post.go
+3
-1
internal/api/routes/post.go
···-func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.OAuthAuthMiddleware) {+func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware middleware.AuthMiddleware) {r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.create", createHandler.HandleCreate)
+79
internal/core/blueskypost/fetcher_test.go
+79
internal/core/blueskypost/fetcher_test.go
···+Description: "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league.",+if result.Embed.URI != "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html" {+if result.Embed.Description != "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league." {
+24
-7
tests/integration/bluesky_post_test.go
+24
-7
tests/integration/bluesky_post_test.go
························
+7
-5
internal/core/communities/token_refresh.go
+7
-5
internal/core/communities/token_refresh.go
···-func refreshPDSToken(ctx context.Context, pdsURL, currentAccessToken, refreshToken string) (newAccessToken, newRefreshToken string, err error) {+func refreshPDSToken(ctx context.Context, pdsURL, refreshToken string) (newAccessToken, newRefreshToken string, err error) {···
+77
internal/db/migrations/024_add_aggregator_api_keys.sql
+77
internal/db/migrations/024_add_aggregator_api_keys.sql
···+COMMENT ON COLUMN aggregators.api_key_prefix IS 'First 12 characters of API key for identification in logs (not secret)';+COMMENT ON COLUMN aggregators.api_key_hash IS 'SHA-256 hash of full API key for authentication lookup';+COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: Encrypted OAuth access token for PDS operations';+COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: Encrypted OAuth refresh token for session renewal';+COMMENT ON COLUMN aggregators.oauth_token_expires_at IS 'When the OAuth access token expires (triggers refresh)';+COMMENT ON COLUMN aggregators.oauth_auth_server_iss IS 'OAuth authorization server issuer URL';+COMMENT ON COLUMN aggregators.oauth_auth_server_token_endpoint IS 'OAuth token refresh endpoint URL';+COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh';+COMMENT ON COLUMN aggregators.oauth_dpop_authserver_nonce IS 'Latest DPoP nonce from authorization server';+COMMENT ON COLUMN aggregators.api_key_revoked_at IS 'When the API key was revoked (NULL = active)';+COMMENT ON COLUMN aggregators.api_key_last_used_at IS 'Last successful authentication using this API key';
+92
internal/db/migrations/025_encrypt_aggregator_oauth_tokens.sql
+92
internal/db/migrations/025_encrypt_aggregator_oauth_tokens.sql
···+THEN pgp_sym_encrypt(oauth_access_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))+THEN pgp_sym_encrypt(oauth_refresh_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))+THEN pgp_sym_encrypt(oauth_dpop_private_key_multibase, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))+COMMENT ON COLUMN aggregators.oauth_access_token_encrypted IS 'SENSITIVE: Encrypted OAuth access token (pgp_sym_encrypt) for PDS operations';+COMMENT ON COLUMN aggregators.oauth_refresh_token_encrypted IS 'SENSITIVE: Encrypted OAuth refresh token (pgp_sym_encrypt) for session renewal';+COMMENT ON COLUMN aggregators.oauth_dpop_private_key_encrypted IS 'SENSITIVE: Encrypted DPoP private key (pgp_sym_encrypt) for token refresh';+THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))+THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))+THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1))+COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: OAuth access token for PDS operations';+COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: OAuth refresh token for session renewal';+COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh';
+42
internal/api/handlers/aggregator/metrics.go
+42
internal/api/handlers/aggregator/metrics.go
···
+7
-1
internal/api/routes/aggregator.go
+7
-1
internal/api/routes/aggregator.go
······
+2
-3
aggregators/kagi-news/.env.example
+2
-3
aggregators/kagi-news/.env.example
···
-1
aggregators/kagi-news/requirements.txt
-1
aggregators/kagi-news/requirements.txt
+24
-15
aggregators/kagi-news/README.md
+24
-15
aggregators/kagi-news/README.md
···Before running the aggregator, you must register it with a Coves instance. This creates a DID for your aggregator and registers it with Coves.+1. **PDS-assigned handle** (simpler): Use `my-aggregator.bsky.social`. No domain verification needed.+2. **Custom domain** (branded): Use `news.example.com`. Requires hosting a `.well-known/atproto-did` file.···-**Manual step required:** During the process, you'll need to upload the `.well-known/atproto-did` file to your domain so it's accessible at `https://yourdomain.com/.well-known/atproto-did`.···See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed documentation on each step.·········- **`COVES_API_URL`** (optional): Override Coves API endpoint (defaults to `https://api.coves.social`)- **`RUN_ON_STARTUP`** (optional): Set to `true` to run immediately on container start (useful for testing)
+148
scripts/aggregator-setup/5-create-api-key.sh
+148
scripts/aggregator-setup/5-create-api-key.sh
···+# - Aggregator indexed by Coves (check: curl https://coves.social/xrpc/social.coves.aggregator.get?did=YOUR_DID)+echo -e "${BLUE}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${BLUE}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${YELLOW}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${YELLOW}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+AGGREGATOR_CHECK=$(curl -s "${COVES_INSTANCE_URL}/xrpc/social.coves.aggregator.get?did=${AGGREGATOR_DID}" 2>/dev/null || echo "error")+echo -e "${YELLOW}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${YELLOW}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${YELLOW}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${YELLOW}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${RED}โ Invalid API key format. Expected: ckapi_ followed by 64 hex characters${NC}"+echo -e "${YELLOW}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${YELLOW}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${GREEN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${GREEN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"
+73
-28
scripts/aggregator-setup/README.md
+73
-28
scripts/aggregator-setup/README.md
···Aggregators are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers. To use aggregators with Coves, you need to:+1. **PDS-assigned handle** (simpler): Use the handle from your PDS, e.g., `my-aggregator.bsky.social`. No domain verification neededโskip steps 2-3.+2. **Custom domain handle** (branded): Use your own domain, e.g., `news.example.com`. Requires hosting a `.well-known/atproto-did` file on your domain.-- **Domain ownership**: You must own a domain where you can host the `.well-known/atproto-did` file···For a reference implementation of automated setup, see the Kagi News aggregator at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh).···+5. **Your aggregator can post** to authorized communities (or all if you're a trusted aggregator)···
+200
scripts/setup_dev_aggregator.go
+200
scripts/setup_dev_aggregator.go
···+err = db.QueryRowContext(ctx, "SELECT handle FROM users WHERE did = $1", did).Scan(&existingHandle)+err = db.QueryRowContext(ctx, "SELECT did FROM aggregators WHERE did = $1", did).Scan(&existingAggDID)+INSERT INTO aggregators (did, display_name, description, record_uri, record_cid, created_at, indexed_at)+resp, err := http.Post(PDSURL+"/xrpc/com.atproto.server.createAccount", "application/json", bytes.NewReader(body))+resp, err := http.Post(PDSURL+"/xrpc/com.atproto.server.createSession", "application/json", bytes.NewReader(body))
+2
-1
aggregators/kagi-news/crontab
+2
-1
aggregators/kagi-news/crontab
···+0 13 * * * . /etc/environment; cd /app && /usr/local/bin/python -m src.main >> /var/log/cron.log 2>&1
+6
-6
internal/core/blobs/service.go
+6
-6
internal/core/blobs/service.go
···-return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize)+return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize)···return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType)-return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize)+return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize)
+28
-17
internal/db/postgres/feed_repo_base.go
+28
-17
internal/db/postgres/feed_repo_base.go
···+// CRITICAL: cursor_timestamp is when the cursor was created, used for stable hot_rank comparison······-filter := fmt.Sprintf(`AND ((%s < $%d OR (%s = $%d AND p.created_at < $%d) OR (%s = $%d AND p.created_at = $%d AND p.uri < $%d)) AND p.uri != $%d)`,+`((p.score + 1) / POWER(EXTRACT(EPOCH FROM ($%d::timestamptz - p.created_at))/3600 + 2, 1.5))`,+// Use tuple comparison for clean keyset pagination: (hot_rank, created_at, uri) < (cursor_values)···-func (r *feedRepoBase) buildCursor(post *posts.PostView, sort string, hotRank float64) string {+// queryTime is the timestamp when the query was executed, used for stable hot_rank comparison+func (r *feedRepoBase) buildCursor(post *posts.PostView, sort string, hotRank float64, queryTime time.Time) string {···payload = fmt.Sprintf("%d%s%s%s%s", score, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)-payload = fmt.Sprintf("%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI)+payload = fmt.Sprintf("%s%s%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI, delimiter, queryTime.Format(time.RFC3339Nano))
+13
-1
internal/core/users/errors.go
+13
-1
internal/core/users/errors.go
···+// ErrHandleAlreadyTaken is returned when attempting to use a handle that belongs to another user
+94
internal/api/handlers/actor/errors.go
+94
internal/api/handlers/actor/errors.go
···+writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred")
+185
internal/api/handlers/actor/get_posts.go
+185
internal/api/handlers/actor/get_posts.go
···+log.Printf("[ACTOR-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved")+// GET /xrpc/social.coves.actor.getPosts?actor={did_or_handle}&filter=posts_with_replies&community=...&limit=50&cursor=...+writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response")+func (h *GetPostsHandler) parseRequest(r *http.Request) (posts.GetAuthorPostsRequest, error) {
+6
internal/core/posts/errors.go
+6
internal/core/posts/errors.go
···
+10
internal/core/posts/interfaces.go
+10
internal/core/posts/interfaces.go
···+GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error)···
+71
internal/core/posts/post.go
+71
internal/core/posts/post.go
···
+122
internal/core/posts/service.go
+122
internal/core/posts/service.go
···log.Printf("[POST-CREATE] Converted Bluesky URL to post embed: %s (cid: %s)", result.URI, result.CID)+func (s *postService) GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error) {+return NewValidationError("filter", "filter must be one of: posts_with_replies, posts_no_replies, posts_with_media")
+285
internal/db/postgres/post_repo.go
+285
internal/db/postgres/post_repo.go
······+func (r *postgresPostRepo) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) {+p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar, c.pds_url as community_pds_url,+func (r *postgresPostRepo) parseAuthorPostsCursor(cursor *string, paramOffset int) (string, []interface{}, error) {
+244
internal/db/postgres/post_repo_cursor_test.go
+244
internal/db/postgres/post_repo_cursor_test.go
···+func (m *mockPostRepository) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) {+func (m *mockPostRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error {
+2
CLAUDE.md
+2
CLAUDE.md
···
+47
-1
Makefile
+47
-1
Makefile
···-.PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean verify-stack create-test-account mobile-full-setup+.PHONY: help dev-up dev-down dev-logs dev-status dev-reset test test-all e2e-test clean verify-stack create-test-account mobile-full-setup······@docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test stop postgres-test+@echo "$(CYAN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"+@echo "$(CYAN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"+@docker-compose -f docker-compose.dev.yml --env-file .env.dev ps 2>/dev/null | grep -q "Up" || \+(echo "$(RED) โ AppView not running. Run 'make run' in another terminal.$(RESET)" && exit 1)+@docker-compose -f docker-compose.dev.yml --env-file .env.dev ps postgres-test 2>/dev/null | grep -q "Up" || \+docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test up -d postgres-test && \+goose -dir internal/db/migrations postgres "postgresql://$(POSTGRES_TEST_USER):$(POSTGRES_TEST_PASSWORD)@localhost:$(POSTGRES_TEST_PORT)/$(POSTGRES_TEST_DB)?sslmode=disable" up)+@echo "$(CYAN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"+@echo "$(CYAN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"+@echo "$(CYAN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"+@echo "$(GREEN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"+@echo "$(GREEN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"
+13
tests/lexicon_validation_test.go
+13
tests/lexicon_validation_test.go
······
+14
tests/unit/community_service_test.go
+14
tests/unit/community_service_test.go
···
+82
-17
tests/e2e/error_recovery_test.go
+82
-17
tests/e2e/error_recovery_test.go
····································
+43
-35
tests/integration/jetstream_consumer_test.go
+43
-35
tests/integration/jetstream_consumer_test.go
············
+42
-21
internal/api/routes/user.go
+42
-21
internal/api/routes/user.go
······+writeXRPCError(w, "InternalError", "failed to encode response", http.StatusInternalServerError)
+33
-1
internal/core/users/service.go
+33
-1
internal/core/users/service.go
···+log.Printf("Warning: database error during handle lookup for %s (falling back to external resolution): %v", handle, err)···+// Returns a ProfileViewDetailed matching the social.coves.actor.defs#profileViewDetailed lexicon.+func (s *userService) GetProfile(ctx context.Context, did string) (*ProfileViewDetailed, error) {
+22
internal/core/users/user.go
+22
internal/core/users/user.go
···
+265
internal/api/handlers/actor/get_comments.go
+265
internal/api/handlers/actor/get_comments.go
···+// GET /xrpc/social.coves.actor.getComments?actor={did_or_handle}&community=...&limit=50&cursor=...+writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to resolve actor identity")+writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response")+func (h *GetCommentsHandler) parseRequest(r *http.Request) (*comments.GetActorCommentsRequest, error) {+return nil, &validationError{field: "actor", message: "actor parameter exceeds maximum length"}+func (h *GetCommentsHandler) populateViewerVoteState(r *http.Request, response *comments.GetActorCommentsResponse) {+writeError(w, http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred")
+617
internal/api/handlers/actor/get_comments_test.go
+617
internal/api/handlers/actor/get_comments_test.go
···+getActorCommentsFunc func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error)+func (m *mockCommentService) GetActorComments(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+func (m *mockCommentService) GetComments(ctx context.Context, req *comments.GetCommentsRequest) (*comments.GetCommentsResponse, error) {+func (m *mockCommentService) CreateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.CreateCommentRequest) (*comments.CreateCommentResponse, error) {+func (m *mockCommentService) UpdateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.UpdateCommentRequest) (*comments.UpdateCommentResponse, error) {+func (m *mockCommentService) DeleteComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.DeleteCommentRequest) error {+func (m *mockUserServiceForComments) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) {+func (m *mockUserServiceForComments) GetUserByDID(ctx context.Context, did string) (*users.User, error) {+func (m *mockUserServiceForComments) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) {+func (m *mockUserServiceForComments) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) {+func (m *mockUserServiceForComments) ResolveHandleToDID(ctx context.Context, handle string) (string, error) {+func (m *mockUserServiceForComments) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) {+func (m *mockUserServiceForComments) IndexUser(ctx context.Context, did, handle, pdsURL string) error {+func (m *mockUserServiceForComments) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) {+func (m *mockVoteServiceForComments) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) {+func (m *mockVoteServiceForComments) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error {+func (m *mockVoteServiceForComments) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error {+func (m *mockVoteServiceForComments) GetViewerVote(userDID, subjectURI string) *votes.CachedVote {+func (m *mockVoteServiceForComments) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote {+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:testuser", nil)+if response.Comments[0].URI != "at://did:plc:testuser/social.coves.community.comment/abc123" {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=abc", nil)+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=nonexistent.user", nil)+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor="+longActor, nil)+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=invalid", nil)+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil)+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:directuser", nil)+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:newuser", nil)+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=testcursor123", nil)+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=25", nil)+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&community=did:plc:community123", nil)+getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) {+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test", nil)+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil)
+9
internal/core/comments/comment.go
+9
internal/core/comments/comment.go
···
+151
internal/db/postgres/comment_repo.go
+151
internal/db/postgres/comment_repo.go
···+func (r *postgresCommentRepo) ListByCommenterWithCursor(ctx context.Context, req comments.ListByCommenterRequest) ([]*comments.Comment, *string, error) {+communityFilter = fmt.Sprintf("AND c.root_uri IN (SELECT uri FROM posts WHERE community_did = $%d)", paramOffset)+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,+// IMPORTANT: This function returns a filter string with hardcoded parameter numbers ($3, $4).+func (r *postgresCommentRepo) parseCommenterCursor(cursor *string) (string, []interface{}, error) {// ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination// Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at)···+log.Printf("WARN: Votes table does not exist, returning empty vote state for %d comments", len(commentURIs))
+14
-56
internal/api/handlers/community/block.go
+14
-56
internal/api/handlers/community/block.go
···-// Extract authenticated user DID and access token from request context (injected by auth middleware)-// This allows users to block by handle: @gaming.community.coves.social or !gaming@coves.social···-// Extract authenticated user DID and access token from request context (injected by auth middleware)-// This allows users to unblock by handle: @gaming.community.coves.social or !gaming@coves.social
+5
internal/atproto/pds/errors.go
+5
internal/atproto/pds/errors.go
···
+3
-5
tests/e2e/user_signup_test.go
+3
-5
tests/e2e/user_signup_test.go
···
+8
-4
tests/integration/community_identifier_resolution_test.go
+8
-4
tests/integration/community_identifier_resolution_test.go
············
+17
tests/integration/helpers.go
+17
tests/integration/helpers.go
······return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)+// CommunityPasswordAuthPDSClientFactory creates a PDSClientFactory for communities that uses password-based Bearer auth.+return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+2
-1
tests/integration/post_creation_test.go
+2
-1
tests/integration/post_creation_test.go
···
+6
-3
tests/integration/post_handler_test.go
+6
-3
tests/integration/post_handler_test.go
·········
+8
-4
tests/integration/post_unfurl_test.go
+8
-4
tests/integration/post_unfurl_test.go
············
+520
internal/api/handlers/community/block_test.go
+520
internal/api/handlers/community/block_test.go
···+blockFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error)+unblockFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error+func (m *blockTestService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) {+func (m *blockTestService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) {+func (m *blockTestService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) {+func (m *blockTestService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {+func (m *blockTestService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {+func (m *blockTestService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) {+func (m *blockTestService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {+func (m *blockTestService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) {+func (m *blockTestService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) {+func (m *blockTestService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) {+func (m *blockTestService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {+func (m *blockTestService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {+func (m *blockTestService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) {+func (m *blockTestService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) {+func (m *blockTestService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) {+func (m *blockTestService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {+func (m *blockTestService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) {+func (m *blockTestService) GetByDID(ctx context.Context, did string) (*communities.Community, error) {+blockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) {+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes))+t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier)+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes))+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes))+blockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) {+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes))+unblockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes))+t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier)+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes))+unblockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes))+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.blockCommunity", nil)+req = httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.unblockCommunity", nil)+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBufferString("invalid json"))
+12
-1
internal/api/handlers/community/errors.go
+12
-1
internal/api/handlers/community/errors.go
······writeError(w, http.StatusForbidden, "Forbidden", "You do not have permission to perform this action")+writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required or session expired")writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred")
+32
internal/api/handlers/community/subscribe_test.go
+32
internal/api/handlers/community/subscribe_test.go
···+req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unsubscribe", bytes.NewBuffer(bodyBytes))
+7
-23
internal/core/communities/service.go
+7
-23
internal/core/communities/service.go
············+log.Printf("[OAUTH_ERROR] getPDSClient called but OAuth client is not configured - check server initialization")···············-// deleteRecordOnPDSAs deletes a record with a specific access token (for user-scoped deletions)-func (s *communityService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey, accessToken string) error {-endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/"))// callPDSWithAuth makes a PDS call with a specific access token (V2: for community authentication)func (s *communityService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) {
+1
-1
cmd/server/main.go
+1
-1
cmd/server/main.go
···+routes.RegisterCommunityRoutes(r, communityService, communityRepo, authMiddleware, allowedCommunityCreators)
+7
-1
internal/api/handlers/community/list.go
+7
-1
internal/api/handlers/community/list.go
·········
+4
internal/core/comments/comment_service_test.go
+4
internal/core/comments/comment_service_test.go
···+func (m *mockCommunityRepo) GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) {func (m *mockCommunityRepo) BlockCommunity(ctx context.Context, block *communities.CommunityBlock) (*communities.CommunityBlock, error) {
+48
internal/db/postgres/community_repo_subscriptions.go
+48
internal/db/postgres/community_repo_subscriptions.go
···+func (r *postgresCommunityRepo) GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) {
+268
tests/integration/community_list_viewer_state_test.go
+268
tests/integration/community_list_viewer_state_test.go
···+func (m *mockCommunityService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) {+func (m *mockCommunityService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) {+func (m *mockCommunityService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) {+func (m *mockCommunityService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {+func (m *mockCommunityService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) {+func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) {+func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {+func (m *mockCommunityService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) {+func (m *mockCommunityService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) {+func (m *mockCommunityService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) {+func (m *mockCommunityService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error {+func (m *mockCommunityService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {+func (m *mockCommunityService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) {+func (m *mockCommunityService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) {+func (m *mockCommunityService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) {+func (m *mockCommunityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) {+func (m *mockCommunityService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) {+func (m *mockCommunityService) GetByDID(ctx context.Context, did string) (*communities.Community, error) {
+117
tests/integration/community_repo_test.go
+117
tests/integration/community_repo_test.go
···
+2
-1
internal/api/routes/oauth.go
+2
-1
internal/api/routes/oauth.go
···
+1
-1
internal/atproto/oauth/handlers.go
+1
-1
internal/atproto/oauth/handlers.go
···