+20
-3
Makefile
+20
-3
Makefile
······
+14
scripts/dev-run.sh
+14
scripts/dev-run.sh
···
+68
scripts/setup-mobile-ports.sh
+68
scripts/setup-mobile-ports.sh
···+echo "Install Android SDK Platform Tools: https://developer.android.com/studio/releases/platform-tools"+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${GREEN}PDS (3000):${NC} localhost:3001 โ device:3000 ${YELLOW}(DID document port)${NC}"+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${YELLOW}๐ก Note: Port forwarding persists until device disconnects or you run:${NC}"
+116
scripts/start-ngrok.sh
+116
scripts/start-ngrok.sh
···+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+PDS_URL=$(echo "$TUNNELS" | jq -r '.tunnels[] | select(.config.addr | contains("3001")) | select(.proto=="https") | .public_url' 2>/dev/null | head -1)+PLC_URL=$(echo "$TUNNELS" | jq -r '.tunnels[] | select(.config.addr | contains("3002")) | select(.proto=="https") | .public_url' 2>/dev/null | head -1)+APPVIEW_URL=$(echo "$TUNNELS" | jq -r '.tunnels[] | select(.config.addr | contains("8081")) | select(.proto=="https") | .public_url' 2>/dev/null | head -1)+URLS=($(echo "$TUNNELS" | jq -r '.tunnels[] | select(.proto=="https") | .public_url' 2>/dev/null))+echo -e "${CYAN}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"+if [[ "$PDS_URL" == "ERROR" ]] || [[ "$PLC_URL" == "ERROR" ]] || [[ "$APPVIEW_URL" == "ERROR" ]]; then
+26
scripts/stop-ngrok.sh
+26
scripts/stop-ngrok.sh
···
-399
internal/core/votes/service.go
-399
internal/core/votes/service.go
···-func (s *voteService) CreateVote(ctx context.Context, voterDID string, userAccessToken string, req CreateVoteRequest) (*CreateVoteResponse, error) {-log.Printf("[VOTE-CREATE] Toggle off: deleting existing %s vote on %s", req.Direction, req.Subject)-if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil {-log.Printf("[VOTE-CREATE] Toggle direction: %s -> %s on %s", existingVoteRecord.Direction, req.Direction, req.Subject)-if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil {-recordURI, recordCID, err := s.createRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", "", voteRecord, userAccessToken)-log.Printf("[VOTE-CREATE] Created %s vote: %s (CID: %s)", req.Direction, recordURI, recordCID)-func (s *voteService) DeleteVote(ctx context.Context, voterDID string, userAccessToken string, req DeleteVoteRequest) error {-if err := s.deleteRecordOnPDSAs(ctx, voterDID, "social.coves.interaction.vote", existingVoteRecord.RKey, userAccessToken); err != nil {-func (s *voteService) GetVote(ctx context.Context, voterDID string, subjectURI string) (*Vote, error) {-func (s *voteService) createRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey string, record map[string]interface{}, accessToken string) (string, string, error) {-endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", strings.TrimSuffix(s.pdsURL, "/"))-func (s *voteService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey, accessToken string) error {-endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/"))-func (s *voteService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) {-// to handle users with >100 votes. Without pagination, votes on older posts would not be found,-func (s *voteService) findVoteOnPDS(ctx context.Context, voterDID, accessToken, subjectURI string) (*PDSVoteRecord, error) {-log.Printf("[VOTE-PDS] Reached max pagination limit (%d pages) searching for vote on %s", maxPages, subjectURI)-endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=social.coves.interaction.vote&limit=100&reverse=true",-log.Printf("[VOTE-PDS] Found existing vote on page %d: %s (direction: %s)", pageCount, record.URI, record.Value.Direction)
-344
internal/core/votes/service_test.go
-344
internal/core/votes/service_test.go
···-func (m *mockVoteRepository) GetByVoterAndSubject(ctx context.Context, voterDID string, subjectURI string) (*Vote, error) {-func (m *mockVoteRepository) ListBySubject(ctx context.Context, subjectURI string, limit, offset int) ([]*Vote, error) {-func (m *mockVoteRepository) ListByVoter(ctx context.Context, voterDID string, limit, offset int) ([]*Vote, error) {-func (m *mockPostRepository) GetByRkey(ctx context.Context, communityDID, rkey string) (*posts.Post, error) {-func (m *mockPostRepository) ListByCommunity(ctx context.Context, communityDID string, limit, offset int) ([]*posts.Post, error) {-t.Skip("Skipping because we need to refactor service to inject HTTP client for testing PDS writes - covered by E2E tests")-mockVoteRepo.On("GetByVoterAndSubject", ctx, voterDID, subjectURI).Return(nil, ErrVoteNotFound)-// NOTE: Testing toggle logic (same direction, different direction) requires mocking HTTP client-mockPDSClient.On("DeleteRecord", voterDID, "social.coves.interaction.vote", "existing").Return(nil)-mockPDSClient.AssertCalled(t, "DeleteRecord", voterDID, "social.coves.interaction.vote", "existing")
+5
-23
internal/core/votes/interfaces.go
+5
-23
internal/core/votes/interfaces.go
···-// Flow: Validate -> Check existing vote -> Handle toggle logic -> Write to user's PDS -> Return URI/CID-CreateVote(ctx context.Context, voterDID string, userAccessToken string, req CreateVoteRequest) (*CreateVoteResponse, error)-DeleteVote(ctx context.Context, voterDID string, userAccessToken string, req DeleteVoteRequest) error···
+10
-31
internal/core/votes/vote.go
+10
-31
internal/core/votes/vote.go
···
-129
internal/api/handlers/vote/create_vote.go
-129
internal/api/handlers/vote/create_vote.go
···-handlers.WriteError(w, http.StatusBadRequest, "InvalidRequest", "direction must be 'up' or 'down'")-// Extract authenticated user DID and access token from request context (injected by auth middleware)-handlers.WriteError(w, http.StatusInternalServerError, "InternalError", "Failed to create vote")
-75
internal/api/handlers/vote/delete_vote.go
-75
internal/api/handlers/vote/delete_vote.go
···-// Extract authenticated user DID and access token from request context (injected by auth middleware)
-24
internal/api/routes/vote.go
-24
internal/api/routes/vote.go
···-func RegisterVoteRoutes(r chi.Router, service votes.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {-r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.interaction.createVote", createVoteHandler.HandleCreateVote)-r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.interaction.deleteVote", deleteVoteHandler.HandleDeleteVote)
-789
tests/integration/vote_e2e_test.go
-789
tests/integration/vote_e2e_test.go
···-// XRPC endpoint โ AppView Service โ PDS write โ (Simulated) Jetstream consumer โ DB indexing-assert.Equal(t, postCID, indexedVote.SubjectCID, "Subject CID should match (strong reference)")-t.Logf("โ E2E test passed! Vote indexed with URI: %s, post upvotes: %d", indexedVote.URI, updatedPost.UpvoteCount)-voteURI := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", idempotentvoter.DID, voteRkey)-_, _ = db.Exec("DELETE FROM votes WHERE voter_did LIKE 'did:plc:votee2elive%' OR voter_did IN (SELECT did FROM users WHERE handle LIKE '%votee2elive%')")-_, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:votee2elive%' OR handle LIKE '%votee2elive%' OR handle LIKE '%authore2e%'")-authMiddleware := middleware.NewAtProtoAuthMiddleware(nil, true) // Skip JWT verification for testing-voterAccessToken, voterDID, err := authenticateWithPDS(pdsURL, instanceHandle, instancePassword)-req := httptest.NewRequest("POST", "/xrpc/social.coves.interaction.createVote", bytes.NewReader(reqJSON))-require.Equal(t, http.StatusOK, rr.Code, "Handler should return 200 OK, body: %s", rr.Body.String())-getRecordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",-jetstreamURL := fmt.Sprintf("ws://%s:6008/subscribe?wantedCollections=social.coves.interaction.vote",-err := subscribeToJetstreamForVote(ctx, jetstreamURL, voterDID, postURI, consumer, eventChan, errorChan, done)-// subscribeToJetstreamForVote subscribes to real Jetstream firehose and processes vote events
-67
internal/atproto/lexicon/social/coves/interaction/createVote.json
-67
internal/atproto/lexicon/social/coves/interaction/createVote.json
···
-37
internal/atproto/lexicon/social/coves/interaction/deleteVote.json
-37
internal/atproto/lexicon/social/coves/interaction/deleteVote.json
···
+27
-27
internal/core/aggregators/aggregator.go
+27
-27
internal/core/aggregators/aggregator.go
···-ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"` // JSON Schema for configuration (JSONB)-MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"` // Contact for support/issues-SourceURL string `json:"sourceUrl,omitempty" db:"source_url"` // Source code URL (transparency)-CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"` // Auto-updated by trigger-CreatedAt time.Time `json:"createdAt" db:"created_at"` // When aggregator was created (from lexicon)-RecordURI string `json:"recordUri,omitempty" db:"record_uri"` // at://did/social.coves.aggregator.service/self// Stored in community's repository: at://community_did/social.coves.aggregator.authorization/{rkey}-DisabledBy string `json:"disabledBy,omitempty" db:"disabled_by"` // Moderator DID who disabled it-DisabledAt *time.Time `json:"disabledAt,omitempty" db:"disabled_at"` // When authorization was disabled (for modlog/audit)-RecordURI string `json:"recordUri,omitempty" db:"record_uri"` // at://community_did/social.coves.aggregator.authorization/{rkey}
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
···+-- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure+COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
+1
-1
tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json
+1
-1
tests/lexicon-test-data/moderation/tribunal-vote-invalid-decision.json
···
+5
-6
tests/lexicon-test-data/post/post-invalid-missing-community.json
+5
-6
tests/lexicon-test-data/post/post-invalid-missing-community.json
···
+6
-7
tests/lexicon-test-data/post/post-valid-text.json
+6
-7
tests/lexicon-test-data/post/post-valid-text.json
···-"text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",+"content": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",···
+5
-5
docs/COMMUNITY_FEEDS.md
+5
-5
docs/COMMUNITY_FEEDS.md
···············
+1
-1
docs/PRD_GOVERNANCE.md
+1
-1
docs/PRD_GOVERNANCE.md
···
+17
-17
docs/PRD_POSTS.md
+17
-17
docs/PRD_POSTS.md
·········-- [x] **Handler:** `POST /xrpc/social.coves.post.create` โ (Alpha - see IMPLEMENTATION_POST_CREATION.md)+- [x] **Handler:** `POST /xrpc/social.coves.community.post.create` โ (Alpha - see IMPLEMENTATION_POST_CREATION.md)···- [x] **E2E Test:** Create text post โ Write to **community's PDS** โ Index via Jetstream โ Verify in AppView โ·········- โ ๏ธ **Derive post characteristics:** DEFERRED (embed_type, text_length, has_title, has_embed for content rules filtering)···············- Post "type" is derived from structure (has embed? what embed type? has title? text length?)
+4
-4
docs/aggregators/PRD_AGGREGATORS.md
+4
-4
docs/aggregators/PRD_AGGREGATORS.md
············
+3
-3
docs/aggregators/PRD_KAGI_NEWS_RSS.md
+3
-3
docs/aggregators/PRD_KAGI_NEWS_RSS.md
···โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ···
+7
-7
internal/api/routes/post.go
+7
-7
internal/api/routes/post.go
···func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {-r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.post.create", createHandler.HandleCreate)+r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.create", createHandler.HandleCreate)-// 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.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.update", updateHandler.HandleUpdate)+// r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.post.delete", deleteHandler.HandleDelete)
-86
internal/atproto/lexicon/social/coves/interaction/comment.json
-86
internal/atproto/lexicon/social/coves/interaction/comment.json
···
-75
internal/atproto/lexicon/social/coves/interaction/createComment.json
-75
internal/atproto/lexicon/social/coves/interaction/createComment.json
···
-41
internal/atproto/lexicon/social/coves/interaction/deleteComment.json
-41
internal/atproto/lexicon/social/coves/interaction/deleteComment.json
···
-118
internal/atproto/lexicon/social/coves/post/create.json
-118
internal/atproto/lexicon/social/coves/post/create.json
···-"description": "Post violates community content rules (e.g., embeds not allowed, text too short)"
-39
internal/atproto/lexicon/social/coves/post/crosspost.json
-39
internal/atproto/lexicon/social/coves/post/crosspost.json
···
-41
internal/atproto/lexicon/social/coves/post/delete.json
-41
internal/atproto/lexicon/social/coves/post/delete.json
···
-294
internal/atproto/lexicon/social/coves/post/get.json
-294
internal/atproto/lexicon/social/coves/post/get.json
···-"description": "Get posts by AT-URI. Supports batch fetching for feed hydration. Returns posts in same order as input URIs.",-"description": "Post is blocked due to viewer blocking author/community, or community moderation",-"description": "What caused the block: viewer blocked author, viewer blocked community, or post was removed by moderators"
-99
internal/atproto/lexicon/social/coves/post/getCrosspostChain.json
-99
internal/atproto/lexicon/social/coves/post/getCrosspostChain.json
···
-129
internal/atproto/lexicon/social/coves/post/record.json
-129
internal/atproto/lexicon/social/coves/post/record.json
···-"description": "DID of the user who created this post. Server-populated from authenticated session; clients MUST NOT provide this field. Required for attribution, moderation, and accountability."-"description": "For microblog posts - information about the original author from federated platform"
-80
internal/atproto/lexicon/social/coves/post/search.json
-80
internal/atproto/lexicon/social/coves/post/search.json
···
-104
internal/atproto/lexicon/social/coves/post/update.json
-104
internal/atproto/lexicon/social/coves/post/update.json
···
+156
internal/atproto/lexicon/com/atproto/label/defs.json
+156
internal/atproto/lexicon/com/atproto/label/defs.json
···+"description": "AT URI of the record, repository (account), or other resource that this label applies to."+"description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."+"description": "Metadata tags on an atproto record, published by the author within the record.",+"description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",+"description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",+"description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",+"description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",+"description": "Does the user need to have adult content enabled in order to configure this label?"+"description": "Strings which describe the label in the UI, localized into a specific language.",
+15
internal/atproto/lexicon/com/atproto/repo/strongRef.json
+15
internal/atproto/lexicon/com/atproto/repo/strongRef.json
···
+8
-5
internal/db/migrations/013_create_votes_table.sql
+8
-5
internal/db/migrations/013_create_votes_table.sql
·········COMMENT ON TABLE votes IS 'Votes indexed from user repositories via Jetstream firehose consumer';-COMMENT ON COLUMN votes.uri IS 'AT-URI in format: at://voter_did/social.coves.interaction.vote/rkey';COMMENT ON INDEX unique_voter_subject_active IS 'Ensures one active vote per user per subject (soft delete aware)';
+9
tests/lexicon-test-data/feed/vote-valid.json
+9
tests/lexicon-test-data/feed/vote-valid.json
-5
tests/lexicon-test-data/interaction/vote-valid.json
-5
tests/lexicon-test-data/interaction/vote-valid.json
-33
internal/atproto/lexicon/social/coves/actor/block.json
-33
internal/atproto/lexicon/social/coves/actor/block.json
···
-59
internal/atproto/lexicon/social/coves/actor/blockUser.json
-59
internal/atproto/lexicon/social/coves/actor/blockUser.json
···
-85
internal/atproto/lexicon/social/coves/actor/getSaved.json
-85
internal/atproto/lexicon/social/coves/actor/getSaved.json
···
-198
internal/atproto/lexicon/social/coves/actor/preferences.json
-198
internal/atproto/lexicon/social/coves/actor/preferences.json
···
-63
internal/atproto/lexicon/social/coves/actor/saveItem.json
-63
internal/atproto/lexicon/social/coves/actor/saveItem.json
···
-37
internal/atproto/lexicon/social/coves/actor/saved.json
-37
internal/atproto/lexicon/social/coves/actor/saved.json
···
-39
internal/atproto/lexicon/social/coves/actor/subscription.json
-39
internal/atproto/lexicon/social/coves/actor/subscription.json
···
-37
internal/atproto/lexicon/social/coves/actor/unblockUser.json
-37
internal/atproto/lexicon/social/coves/actor/unblockUser.json
···
-37
internal/atproto/lexicon/social/coves/actor/unsaveItem.json
-37
internal/atproto/lexicon/social/coves/actor/unsaveItem.json
···
-5
tests/lexicon-test-data/actor/block-invalid-did.json
-5
tests/lexicon-test-data/actor/block-invalid-did.json
-6
tests/lexicon-test-data/actor/block-valid.json
-6
tests/lexicon-test-data/actor/block-valid.json
-7
tests/lexicon-test-data/actor/preferences-invalid-enum.json
-7
tests/lexicon-test-data/actor/preferences-invalid-enum.json
-40
tests/lexicon-test-data/actor/preferences-valid.json
-40
tests/lexicon-test-data/actor/preferences-valid.json
···
-6
tests/lexicon-test-data/actor/profile-invalid-handle-format.json
-6
tests/lexicon-test-data/actor/profile-invalid-handle-format.json
-4
tests/lexicon-test-data/actor/profile-invalid-missing-handle.json
-4
tests/lexicon-test-data/actor/profile-invalid-missing-handle.json
-1
tests/lexicon-test-data/actor/profile-valid.json
-1
tests/lexicon-test-data/actor/profile-valid.json
-32
internal/atproto/lexicon/social/coves/federation/post.json
-32
internal/atproto/lexicon/social/coves/federation/post.json
···
-31
internal/atproto/lexicon/social/coves/interaction/share.json
-31
internal/atproto/lexicon/social/coves/interaction/share.json
···
-33
internal/atproto/lexicon/social/coves/interaction/tag.json
-33
internal/atproto/lexicon/social/coves/interaction/tag.json
···
-9
tests/lexicon-test-data/community/moderator-invalid-permissions.json
-9
tests/lexicon-test-data/community/moderator-invalid-permissions.json
···
-5
tests/lexicon-test-data/interaction/share-valid-no-community.json
-5
tests/lexicon-test-data/interaction/share-valid-no-community.json
-6
tests/lexicon-test-data/interaction/share-valid.json
-6
tests/lexicon-test-data/interaction/share-valid.json
-6
tests/lexicon-test-data/interaction/tag-invalid-empty.json
-6
tests/lexicon-test-data/interaction/tag-invalid-empty.json
-6
tests/lexicon-test-data/interaction/tag-valid-custom.json
-6
tests/lexicon-test-data/interaction/tag-valid-custom.json
-6
tests/lexicon-test-data/interaction/tag-valid-known.json
-6
tests/lexicon-test-data/interaction/tag-valid-known.json
+13
scripts/validate-schemas.sh
+13
scripts/validate-schemas.sh
+63
internal/db/migrations/016_create_comments_table.sql
+63
internal/db/migrations/016_create_comments_table.sql
···+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;+COMMENT ON TABLE comments IS 'Comments indexed from user repositories via Jetstream firehose consumer';+COMMENT ON COLUMN comments.uri IS 'AT-URI in format: at://commenter_did/social.coves.feed.comment/rkey';+COMMENT ON COLUMN comments.root_uri IS 'Strong reference to the original post that started the thread';+COMMENT ON COLUMN comments.parent_uri IS 'Strong reference to immediate parent (post or comment)';+COMMENT ON COLUMN comments.score IS 'Computed as upvote_count - downvote_count for ranking replies';+COMMENT ON COLUMN comments.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
+125
internal/atproto/jetstream/comment_jetstream_connector.go
+125
internal/atproto/jetstream/comment_jetstream_connector.go
···+func NewCommentJetstreamConnector(consumer *CommentEventConsumer, wsURL string) *CommentJetstreamConnector {+if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
+11
internal/core/comments/interfaces.go
+11
internal/core/comments/interfaces.go
···GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
+6
-6
tests/lexicon_validation_test.go
+6
-6
tests/lexicon_validation_test.go
···
+200
internal/core/unfurl/circuit_breaker.go
+200
internal/core/unfurl/circuit_breaker.go
···+"[UNFURL-CIRCUIT] Opening circuit for provider '%s' after %d consecutive failures. Last error: %v",
+175
internal/core/unfurl/circuit_breaker_test.go
+175
internal/core/unfurl/circuit_breaker_test.go
···
+202
internal/core/unfurl/kagi_test.go
+202
internal/core/unfurl/kagi_test.go
···
+269
internal/core/unfurl/opengraph_test.go
+269
internal/core/unfurl/opengraph_test.go
···+assert.Equal(t, "Meta description fallback", og.Description, "Should fall back to meta description")
+170
internal/core/unfurl/service.go
+170
internal/core/unfurl/service.go
···
+27
internal/core/unfurl/types.go
+27
internal/core/unfurl/types.go
···
+14
internal/core/unfurl/errors.go
+14
internal/core/unfurl/errors.go
···
+19
internal/core/unfurl/interfaces.go
+19
internal/core/unfurl/interfaces.go
···
+117
internal/core/unfurl/repository.go
+117
internal/core/unfurl/repository.go
···+func (r *postgresUnfurlRepo) Set(ctx context.Context, url string, result *UnfurlResult, ttl time.Duration) error {+_, err = r.db.ExecContext(ctx, query, url, result.Provider, metadataJSON, thumbnailURL, intervalStr)
+23
internal/db/migrations/017_create_unfurl_cache.sql
+23
internal/db/migrations/017_create_unfurl_cache.sql
···+COMMENT ON TABLE unfurl_cache IS 'Cache for oEmbed/URL unfurl results to reduce external API calls';+COMMENT ON COLUMN unfurl_cache.provider IS 'Provider name (streamable, youtube, reddit, etc.)';+COMMENT ON COLUMN unfurl_cache.metadata IS 'Full unfurl result as JSON (title, description, type, etc.)';+COMMENT ON COLUMN unfurl_cache.expires_at IS 'When this cache entry should be refetched (TTL-based)';
+9
internal/core/blobs/types.go
+9
internal/core/blobs/types.go
+7
-1
internal/core/posts/interfaces.go
+7
-1
internal/core/posts/interfaces.go
···+// When unfurlService is provided, external embeds will be automatically enriched with metadata.
+81
internal/core/posts/blob_transform.go
+81
internal/core/posts/blob_transform.go
···
+312
internal/core/posts/blob_transform_test.go
+312
internal/core/posts/blob_transform_test.go
···+"http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:testcommunity&cid=bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",+expectedURL := "http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafytest"+"http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm",+expectedURL := "http://localhost:3001/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafytest"
+13
-14
aggregators/kagi-news/src/coves_client.py
+13
-14
aggregators/kagi-news/src/coves_client.py
···············
+5
-14
aggregators/kagi-news/tests/test_e2e.py
+5
-14
aggregators/kagi-news/tests/test_e2e.py
······
+4
-3
aggregators/kagi-news/tests/test_main.py
+4
-3
aggregators/kagi-news/tests/test_main.py
······
+134
scripts/post_streamable.py
+134
scripts/post_streamable.py
···+STREAMABLE_TITLE = "NBACentral - \"Your son don't wanna be here, we know it's your last weekend. Enjoy ..."+REDDIT_URL = "https://www.reddit.com/r/nba/comments/1orfsgm/highlight_giannis_antetokounmpo_41_pts_15_reb_9/"+REDDIT_TITLE = "[Highlight] Giannis Antetokounmpo (41 PTS, 15 REB, 9 AST) tallies his 56th career regular season game of 40+ points, passing Kareem Abdul-Jabbar for the most such games in franchise history. Milwaukee defeats Chicago 126-110 to win their NBA Cup opener."
+6
-3
aggregators/kagi-news/src/html_parser.py
+6
-3
aggregators/kagi-news/src/html_parser.py
·········
+1
aggregators/kagi-news/src/models.py
+1
aggregators/kagi-news/src/models.py
+11
-7
aggregators/kagi-news/src/richtext_formatter.py
+11
-7
aggregators/kagi-news/src/richtext_formatter.py
·········
+34
internal/db/migrations/018_migrate_comment_namespace.sql
+34
internal/db/migrations/018_migrate_comment_namespace.sql
···+-- Migration: Update comment URIs from social.coves.feed.comment to social.coves.community.comment+SET root_uri = REPLACE(root_uri, '/social.coves.feed.comment/', '/social.coves.community.comment/')+SET parent_uri = REPLACE(parent_uri, '/social.coves.feed.comment/', '/social.coves.community.comment/')+-- Rollback: Revert comment URIs from social.coves.community.comment to social.coves.feed.comment+SET root_uri = REPLACE(root_uri, '/social.coves.community.comment/', '/social.coves.feed.comment/')+SET parent_uri = REPLACE(parent_uri, '/social.coves.community.comment/', '/social.coves.feed.comment/')
+2
-2
internal/core/comments/view_models.go
+2
-2
internal/core/comments/view_models.go
······
+1
-1
internal/validation/lexicon.go
+1
-1
internal/validation/lexicon.go
···
+18
-15
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
+18
-15
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
···············-commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"+commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.community.comment"·········+**Note:** Since we're pre-production, no historical data migration was needed. Migration script updates URIs in comments table (uri, root_uri, parent_uri columns).···-export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"+export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.community.comment"
+3
-2
internal/core/unfurl/providers.go
+3
-2
internal/core/unfurl/providers.go
···
+785
docs/federation-prd.md
+785
docs/federation-prd.md
···+Enable Lemmy-style federation where users on any Coves instance can post to communities hosted on other instances, while maintaining community ownership and moderation control.+1. **Enable cross-instance posting** - Users can post to any community on any federated instance+โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ+โ @coves.soc โโโโโโโโโโถโ AppView โโโโโโโโโโถโ .com PDS โ+โโโโโโโโโโโโโโโ (1) โโโโโโโโโโโโโโโโโโโโ (2) โโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ+func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) {+func (c *serviceAuthClient) RequestServiceAuth(ctx context.Context, opts ServiceAuthOptions) (*ServiceAuthToken, error) {+func (s *postService) createFederatedPost(ctx context.Context, community *communities.Community, req CreatePostRequest) (*CreatePostResponse, error) {+uri, cid, err := s.createPostOnRemotePDS(ctx, community.PDSURL, community.DID, postRecord, token.Token)+writeError(w, http.StatusForbidden, "FederationDisabled", "Community doesn't accept federated posts")+-- federation_mode: 'open' (any instance), 'allowlist' (trusted only), 'local' (no federation)+**No data loss:** Posts are written to PDS, indexed via firehose regardless of federation method.+4. **Federation Symmetry:** If instance-a trusts instance-b, does instance-b auto-trust instance-a?+- [atProto Service Auth Spec](https://atproto.com/specs/service-auth) (hypothetical - check actual docs)+| Instance not trusted | 403 | `UntrustedInstance` | "This community doesn't accept posts from your instance" | No retry |+| Rate limit exceeded | 429 | `RateLimited` | "Too many posts. Try again in X minutes" | Exponential backoff |+| PDS unreachable | 503 | `ServiceUnavailable` | "Community temporarily unavailable" | Retry 3x with backoff |+| Invalid token | 401 | `InvalidToken` | "Session expired. Please try again" | Refresh token & retry |+| Service auth failed | 500 | `FederationFailed` | "Unable to connect. Try again later" | Retry 2x |
+130
-28
docs/PRD_ALPHA_GO_LIVE.md
+130
-28
docs/PRD_ALPHA_GO_LIVE.md
······**Test Quality**: Enhanced with comprehensive database record verification to catch race conditions+- **Jetstream**: Connects to Bluesky's production firehose (wss://jetstream2.us-east.bsky.network)This document tracks the remaining work required to launch Coves alpha with real users. Focus is on critical functionality, security, and operational readiness.···+**Important**: Jetstream connects to Bluesky's production firehose, which automatically includes events from all production PDS instances (including coves.me once it's live)+- **Location**: [internal/atproto/jetstream/community_consumer.go](../internal/atproto/jetstream/community_consumer.go)······+**Total**: 16-23 hours (added 4-6 hours for PDS deployment, reduced from original due to did:web completion)···**Total**: ~~20-25 hours~~ โ **13 hours actual** (E2E tests) + 7-12 hours remaining (load testing, polish)-**Grand Total: ~~65-80 hours~~ โ 50-65 hours remaining (approximately 1.5-2 weeks full-time)**-*(Originally 70-85 hours. Reduced by completed items: handle resolution, comment count reconciliation, and ALL E2E tests)*+**Grand Total: ~~65-80 hours~~ โ 51-68 hours remaining (approximately 1.5-2 weeks full-time)**+*(Originally 70-85 hours. Adjusted for: +4-6 hours PDS deployment, -3 hours did:web completion, -13 hours E2E tests completion, -4 hours handle resolution and comment reconciliation)***โ Progress Update**: E2E testing section COMPLETE ahead of schedule - saved ~7-12 hours through parallel agent implementation······-**๐ Major Milestone**: All E2E tests complete! Test coverage now includes full user journey, blob uploads, concurrent operations, rate limiting, and error recovery.+- All E2E tests complete! Test coverage now includes full user journey, blob uploads, concurrent operations, rate limiting, and error recovery.+- Bidirectional DID verification complete! Bluesky-compatible security model with alsoKnownAs validation, 24h cache TTL, and comprehensive test coverage.
+18
internal/api/routes/aggregator.go
+18
internal/api/routes/aggregator.go
·········r.Get("/xrpc/social.coves.aggregator.listForCommunity", listForCommunityHandler.HandleListForCommunity)+registrationRateLimiter.Middleware(http.HandlerFunc(registerHandler.HandleRegister)).ServeHTTP)
+591
docs/aggregators/SETUP_GUIDE.md
+591
docs/aggregators/SETUP_GUIDE.md
···+**Aggregators** are automated services that post content to Coves communities. They are similar to Bluesky's feed generators and labelers - self-managed external services that integrate with the platform.+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed script documentation.+Your aggregator needs its own atProto identity (DID). The easiest way is to create an account on an existing PDS.+**Save these credentials securely!** You'll need the DID and access token for all subsequent operations.+To register with Coves, you must prove you own a domain by serving your DID at `https://yourdomain.com/.well-known/atproto-did`.+"message": "Aggregator registered successfully. Next step: create a service declaration record at at://did:plc:abc123.../social.coves.aggregator.service/self"+Write a `social.coves.aggregator.service` record to your repository. This contains metadata about your aggregator and gets indexed by Coves' Jetstream consumer.+**Wait 5-10 seconds** for Jetstream to index your service declaration into the `aggregators` table.+2. **Authorization record**: Moderator writes `social.coves.aggregator.authorization` to community's repo+curl "https://api.coves.social/xrpc/social.coves.aggregator.getAuthorizations?aggregatorDid=did:plc:abc123...&enabledOnly=true"+3. Verify record was created: Check PDS at `at://your-did/social.coves.aggregator.service/self`
+95
scripts/aggregator-setup/1-create-pds-account.sh
+95
scripts/aggregator-setup/1-create-pds-account.sh
···
+93
scripts/aggregator-setup/2-setup-wellknown.sh
+93
scripts/aggregator-setup/2-setup-wellknown.sh
···
+103
scripts/aggregator-setup/3-register-with-coves.sh
+103
scripts/aggregator-setup/3-register-with-coves.sh
···
+125
scripts/aggregator-setup/4-create-service-declaration.sh
+125
scripts/aggregator-setup/4-create-service-declaration.sh
···
+252
scripts/aggregator-setup/README.md
+252
scripts/aggregator-setup/README.md
···+This directory contains scripts to help you set up and register your aggregator with Coves instances.+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:+- **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).+The Kagi script shows how to automate all 4 steps (with the manual .well-known upload step in between).+1. Creates a `social.coves.aggregator.service` record at `at://your-did/social.coves.aggregator.service/self`+Authorizations are created by community moderators, not by aggregators. The moderator writes a `social.coves.aggregator.authorization` record to their community's repository.+- Verify `.well-known/atproto-did` is accessible: `curl https://yourdomain.com/.well-known/atproto-did`+- Verify the record was created: Check your PDS at `at://your-did/social.coves.aggregator.service/self`+For a complete reference implementation, see the Kagi News aggregator at `aggregators/kagi-news/`.+The Kagi aggregator includes an automated setup script at [aggregators/kagi-news/scripts/setup.sh](../../aggregators/kagi-news/scripts/setup.sh) that demonstrates how to:+This shows how you can package scripts 1-4 into a single automated flow for your specific aggregator.
+195
aggregators/kagi-news/scripts/setup.sh
+195
aggregators/kagi-news/scripts/setup.sh
···
+55
aggregators/kagi-news/.dockerignore
+55
aggregators/kagi-news/.dockerignore
···
+53
aggregators/kagi-news/Dockerfile
+53
aggregators/kagi-news/Dockerfile
···
+48
aggregators/kagi-news/docker-compose.yml
+48
aggregators/kagi-news/docker-compose.yml
···
+41
aggregators/kagi-news/docker-entrypoint.sh
+41
aggregators/kagi-news/docker-entrypoint.sh
···
+20
.beads/.gitignore
+20
.beads/.gitignore
···
+56
.beads/config.yaml
+56
.beads/config.yaml
···+# - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set)
+4
.beads/metadata.json
+4
.beads/metadata.json
+3
.gitattributes
+3
.gitattributes
+131
AGENTS.md
+131
AGENTS.md
···+**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.+6. **Commit together**: Always commit the `.beads/issues.jsonl` file together with the code changes so issue state stays in sync with code state
+15
CLAUDE.md
+15
CLAUDE.md
······+**This project uses [bd (beads)](https://github.com/steveyegge/beads) for ALL issue tracking.**
+8
-8
internal/core/communities/community.go
+8
-8
internal/core/communities/community.go
······
+2
-2
internal/core/communities/interfaces.go
+2
-2
internal/core/communities/interfaces.go
···-List(ctx context.Context, req ListCommunitiesRequest) ([]*Community, int, error) // Returns communities + total count···GetCommunity(ctx context.Context, identifier string) (*Community, error) // identifier can be DID or handleSearchCommunities(ctx context.Context, req SearchCommunitiesRequest) ([]*Community, int, error)
+57
scripts/backup.sh
+57
scripts/backup.sh
···+log "To restore: gunzip -c $BACKUP_FILE | docker compose -f docker-compose.prod.yml exec -T postgres psql -U $POSTGRES_USER -d $POSTGRES_DB"
+133
scripts/deploy.sh
+133
scripts/deploy.sh
···+if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then+if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then+if docker compose -f "$COMPOSE_FILE" exec -T appview /app/coves-server migrate 2>/dev/null; then+warn "โ ๏ธ Migration command not available or failed - AppView will run migrations on startup"+if docker compose -f "$COMPOSE_FILE" exec -T appview wget --spider -q http://localhost:8080/xrpc/_health 2>/dev/null; then+warn "โ ๏ธ AppView health check failed - check logs with: docker compose -f docker-compose.prod.yml logs appview"+if docker compose -f "$COMPOSE_FILE" exec -T pds wget --spider -q http://localhost:3000/xrpc/_health 2>/dev/null; then+warn "โ ๏ธ PDS health check failed - check logs with: docker compose -f docker-compose.prod.yml logs pds"+log " Rollback: docker compose -f docker-compose.prod.yml down && git checkout HEAD~1 && ./scripts/deploy.sh"
+149
scripts/generate-did-keys.sh
+149
scripts/generate-did-keys.sh
···+PUBLIC_KEY_HEX=$(openssl ec -in "$PRIVATE_KEY_PEM" -pubout -conv_form compressed -outform DER 2>/dev/null | \
+106
scripts/setup-production.sh
+106
scripts/setup-production.sh
···+until docker compose -f docker-compose.prod.yml exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do
+19
static/.well-known/did.json.template
+19
static/.well-known/did.json.template
···
+18
static/client-metadata.json
+18
static/client-metadata.json
···
+97
static/oauth/callback.html
+97
static/oauth/callback.html
···+<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'">
+2
-1
Dockerfile
+2
-1
Dockerfile
···
+187
scripts/derive-did-from-key.sh
+187
scripts/derive-did-from-key.sh
···+PUBLIC_KEY_HEX=$(openssl ec -in "$TEMP_DIR/private.pem" -pubout -conv_form compressed -outform DER 2>/dev/null | \
+3
-2
internal/api/routes/community.go
+3
-2
internal/api/routes/community.go
···-func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {+func RegisterCommunityRoutes(r chi.Router, service communities.Service, authMiddleware *middleware.AtProtoAuthMiddleware, allowedCommunityCreators []string) {
+1
-2
internal/api/handlers/aggregator/list_for_community.go
+1
-2
internal/api/handlers/aggregator/list_for_community.go
+1
-2
internal/api/handlers/comments/errors.go
+1
-2
internal/api/handlers/comments/errors.go
+1
-2
internal/api/handlers/comments/service_adapter.go
+1
-2
internal/api/handlers/comments/service_adapter.go
+2
-3
internal/api/handlers/community/block.go
+2
-3
internal/api/handlers/community/block.go
···
+1
-2
internal/api/handlers/community/list.go
+1
-2
internal/api/handlers/community/list.go
+2
-3
internal/api/handlers/communityFeed/get_community.go
+2
-3
internal/api/handlers/communityFeed/get_community.go
···
+2
-3
internal/api/handlers/discover/get_discover.go
+2
-3
internal/api/handlers/discover/get_discover.go
···
+2
-3
internal/api/handlers/post/create.go
+2
-3
internal/api/handlers/post/create.go
···
+2
-3
internal/api/handlers/post/errors.go
+2
-3
internal/api/handlers/post/errors.go
+3
-4
internal/api/handlers/timeline/get_timeline.go
+3
-4
internal/api/handlers/timeline/get_timeline.go
···
+1
-2
internal/atproto/jetstream/aggregator_consumer.go
+1
-2
internal/atproto/jetstream/aggregator_consumer.go
+2
-3
internal/atproto/jetstream/comment_consumer.go
+2
-3
internal/atproto/jetstream/comment_consumer.go
······
+3
-4
internal/atproto/jetstream/community_consumer.go
+3
-4
internal/atproto/jetstream/community_consumer.go
······
+3
-4
internal/atproto/jetstream/post_consumer.go
+3
-4
internal/atproto/jetstream/post_consumer.go
······
+3
-4
internal/atproto/jetstream/vote_consumer.go
+3
-4
internal/atproto/jetstream/vote_consumer.go
···
+1
-2
internal/core/blobs/service.go
+1
-2
internal/core/blobs/service.go
+3
-4
internal/core/comments/comment_service.go
+3
-4
internal/core/comments/comment_service.go
······
+5
-6
internal/core/comments/comment_service_test.go
+5
-6
internal/core/comments/comment_service_test.go
······-func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) {+func (m *mockCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) {
+1
-2
internal/core/communities/service.go
+1
-2
internal/core/communities/service.go
······
+5
-6
internal/core/posts/service.go
+5
-6
internal/core/posts/service.go
······
+1
-2
internal/db/postgres/aggregator_repo.go
+1
-2
internal/db/postgres/aggregator_repo.go
+1
-2
internal/db/postgres/comment_repo.go
+1
-2
internal/db/postgres/comment_repo.go
+1
-2
internal/db/postgres/feed_repo.go
+1
-2
internal/db/postgres/feed_repo.go
+1
-2
internal/db/postgres/feed_repo_base.go
+1
-2
internal/db/postgres/feed_repo_base.go
······
+1
-2
internal/db/postgres/post_repo.go
+1
-2
internal/db/postgres/post_repo.go
+1
-2
internal/db/postgres/vote_repo.go
+1
-2
internal/db/postgres/vote_repo.go
+1
-2
internal/db/postgres/vote_repo_test.go
+1
-2
internal/db/postgres/vote_repo_test.go
+7
-8
tests/e2e/error_recovery_test.go
+7
-8
tests/e2e/error_recovery_test.go
············+_, _ = w.Write([]byte(`{"error":"ServiceUnavailable","message":"PDS temporarily unavailable"}`))
+3
-4
tests/integration/aggregator_test.go
+3
-4
tests/integration/aggregator_test.go
···
+13
-14
tests/integration/blob_upload_e2e_test.go
+13
-14
tests/integration/blob_upload_e2e_test.go
···············
+14
-14
tests/integration/block_handle_resolution_test.go
+14
-14
tests/integration/block_handle_resolution_test.go
·······································
+3
-4
tests/integration/comment_consumer_test.go
+3
-4
tests/integration/comment_consumer_test.go
···
+3
-4
tests/integration/comment_query_test.go
+3
-4
tests/integration/comment_query_test.go
······
+4
-5
tests/integration/comment_vote_test.go
+4
-5
tests/integration/comment_vote_test.go
···
+2
-3
tests/integration/community_blocking_test.go
+2
-3
tests/integration/community_blocking_test.go
···
+13
-14
tests/integration/community_e2e_test.go
+13
-14
tests/integration/community_e2e_test.go
·····················
+2
-3
tests/integration/community_repo_test.go
+2
-3
tests/integration/community_repo_test.go
···
+3
-4
tests/integration/community_v2_validation_test.go
+3
-4
tests/integration/community_v2_validation_test.go
···
+6
-7
tests/integration/concurrent_scenarios_test.go
+6
-7
tests/integration/concurrent_scenarios_test.go
······
+2
-3
tests/integration/discover_test.go
+2
-3
tests/integration/discover_test.go
······
+4
-5
tests/integration/feed_test.go
+4
-5
tests/integration/feed_test.go
······
+3
-4
tests/integration/jwt_verification_test.go
+3
-4
tests/integration/jwt_verification_test.go
·········
+3
-4
tests/integration/post_consumer_test.go
+3
-4
tests/integration/post_consumer_test.go
···
+8
-9
tests/integration/post_e2e_test.go
+8
-9
tests/integration/post_e2e_test.go
······
+5
-6
tests/integration/post_handler_test.go
+5
-6
tests/integration/post_handler_test.go
······
+5
-6
tests/integration/post_thumb_validation_test.go
+5
-6
tests/integration/post_thumb_validation_test.go
······
+5
-6
tests/integration/post_unfurl_test.go
+5
-6
tests/integration/post_unfurl_test.go
······
+2
-3
tests/integration/subscription_indexing_test.go
+2
-3
tests/integration/subscription_indexing_test.go
···
+3
-4
tests/integration/timeline_test.go
+3
-4
tests/integration/timeline_test.go
······
+11
-11
tests/integration/user_journey_e2e_test.go
+11
-11
tests/integration/user_journey_e2e_test.go
···············
+23
static/.well-known/did.json
+23
static/.well-known/did.json
···
+1
-1
docs/E2E_TESTING.md
+1
-1
docs/E2E_TESTING.md
+3
-3
internal/api/routes/user.go
+3
-3
internal/api/routes/user.go
···
+52
internal/atproto/auth/combined_key_fetcher.go
+52
internal/atproto/auth/combined_key_fetcher.go
···+func NewCombinedKeyFetcher(directory indigoIdentity.Directory, jwksFetcher JWKSFetcher) *CombinedKeyFetcher {+func (f *CombinedKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+116
internal/atproto/auth/did_key_fetcher.go
+116
internal/atproto/auth/did_key_fetcher.go
···+func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
+24
-3
cmd/server/main.go
+24
-3
cmd/server/main.go
······
+5
.env.dev
+5
.env.dev
···
+484
internal/atproto/auth/dpop.go
+484
internal/atproto/auth/dpop.go
···+func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, error) {+verifiedToken, err := jwt.ParseWithClaims(dpopProof, &DPoPClaims{}, func(token *jwt.Token) (interface{}, error) {+func (v *DPoPVerifier) validateDPoPClaims(claims *DPoPClaims, expectedMethod, expectedURI string) error {+return fmt.Errorf("DPoP proof htm mismatch: expected %s, got %s", expectedMethod, claims.HTTPMethod)+return fmt.Errorf("DPoP proof htu mismatch: expected %s, got %s", expectedURIBase, claimURIBase)+return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge)+func (v *DPoPVerifier) VerifyTokenBinding(proof *DPoPProof, expectedThumbprint string) error {+// Serialize to JSON (Go's json.Marshal produces lexicographically ordered keys for map[string]string)
+921
internal/atproto/auth/dpop_test.go
+921
internal/atproto/auth/dpop_test.go
···+func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string {
+148
-6
internal/api/middleware/auth.go
+148
-6
internal/api/middleware/auth.go
······func NewAtProtoAuthMiddleware(jwksFetcher auth.JWKSFetcher, skipVerify bool) *AtProtoAuthMiddleware {······+log.Printf("[AUTH_FAILURE] type=missing_dpop ip=%s method=%s path=%s error=token has cnf.jkt but no DPoP header",+log.Printf("[AUTH_WARNING] type=unexpected_dpop ip=%s method=%s path=%s warning=DPoP header present but token has no cnf.jkt",······+log.Printf("[AUTH_WARNING] Optional auth: token has cnf.jkt but no DPoP header - treating as unauthenticated (potential token theft)")+log.Printf("[AUTH_WARNING] Optional auth: DPoP verification failed - treating as unauthenticated: %v", err)···+func (m *AtProtoAuthMiddleware) verifyDPoPBinding(r *http.Request, claims *auth.Claims, dpopProofHeader string) (*auth.DPoPProof, error) {
+416
internal/api/middleware/auth_test.go
+416
internal/api/middleware/auth_test.go
·········+// TestGetDPoPProof_NotAuthenticated tests that GetDPoPProof returns nil when no DPoP was verified+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+middleware := NewAtProtoAuthMiddleware(fetcher, false) // skipVerify=false - REAL verification+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+t.Errorf("SECURITY VULNERABILITY: Expected 401, got %d. Token was not properly verified!", w.Code)+handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+4
-1
internal/atproto/auth/jwt.go
+4
-1
internal/atproto/auth/jwt.go
···
+134
-2
internal/atproto/auth/README.md
+134
-2
internal/atproto/auth/README.md
···+DPoP (Demonstrating Proof-of-Possession) binds access tokens to client-controlled cryptographic keys, preventing token theft and replay attacks.+DPoP is an OAuth extension (RFC 9449) that adds proof-of-possession semantics to bearer tokens. When a PDS issues a DPoP-bound access token:+> โ ๏ธ **DPoP is an ADDITIONAL security layer, NOT a replacement for token signature verification.**+1. **ALWAYS verify the access token signature first** (via JWKS, HS256 shared secret, or DID resolution)+**Why This Matters**: An attacker could create a fake token with `sub: "did:plc:victim"` and their own `cnf.jkt`, then present a valid DPoP proof signed with their key. If we accept DPoP as a fallback, the attacker can impersonate any user.+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ>โ+โ<โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+DPoP proofs include a unique `jti` (JWT ID) claim. The server tracks seen `jti` values to prevent replay attacks:+// The verifier automatically rejects reused jti values within the proof validity window (5 minutes)······
+4
-1
.gitignore
+4
-1
.gitignore
+5
-6
go.mod
+5
-6
go.mod
·········
+6
-8
go.sum
+6
-8
go.sum
···-github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=-github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=-github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=+github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36 h1:Vc+l4sltxQfBT8qC3dm87PRYInmxlGyF1dmpjaW0WkU=+github.com/bluesky-social/indigo v0.0.0-20251127021457-6f2658724b36/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=···github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=+github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=+github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=······github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=