+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
···············
+4
-3
aggregators/kagi-news/src/main.py
+4
-3
aggregators/kagi-news/src/main.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
·········
+1
-1
tests/lexicon-test-data/interaction/comment-invalid-content.json
+1
-1
tests/lexicon-test-data/interaction/comment-invalid-content.json
+1
-1
tests/lexicon-test-data/interaction/comment-valid-sticker.json
+1
-1
tests/lexicon-test-data/interaction/comment-valid-sticker.json
+1
-1
tests/lexicon-test-data/interaction/comment-valid-text.json
+1
-1
tests/lexicon-test-data/interaction/comment-valid-text.json
+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/')
+1
-1
internal/validation/lexicon.go
+1
-1
internal/validation/lexicon.go
···
+3
-2
internal/core/unfurl/providers.go
+3
-2
internal/core/unfurl/providers.go
···
+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)
+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
···
+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
+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
···
+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 | \
+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/post/errors.go
+2
-3
internal/api/handlers/post/errors.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
······
+1
-2
internal/core/blobs/service.go
+1
-2
internal/core/blobs/service.go
+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
······
+2
-4
internal/db/postgres/community_repo.go
+2
-4
internal/db/postgres/community_repo.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"}`))
+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
···
+4
-5
tests/integration/community_hostedby_security_test.go
+4
-5
tests/integration/community_hostedby_security_test.go
······// TestHostedByVerification_DomainMatching tests that hostedBy domain must match handle domain······
+2
-3
tests/integration/community_repo_test.go
+2
-3
tests/integration/community_repo_test.go
···
+3
-4
tests/integration/post_consumer_test.go
+3
-4
tests/integration/post_consumer_test.go
···
+4
-5
tests/integration/post_creation_test.go
+4
-5
tests/integration/post_creation_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
······
+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
···
+7
-4
docs/FEED_SYSTEM_IMPLEMENTATION.md
+7
-4
docs/FEED_SYSTEM_IMPLEMENTATION.md
···'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=top&timeframe=week&limit=20' \'http://localhost:8081/xrpc/social.coves.feed.getTimeline?sort=new&limit=10&cursor=<cursor>' \···
+3
-3
docs/PRD_OAUTH.md
+3
-3
docs/PRD_OAUTH.md
······Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6ImRpZDpwbGM6YWxpY2UjYXRwcm90by1wZHMifQ...···
+477
internal/atproto/oauth/handlers_security_test.go
+477
internal/atproto/oauth/handlers_security_test.go
···+// TestIsAllowedMobileRedirectURI tests the mobile redirect URI allowlist with EXACT URI matching+assert.Greater(t, len(token1), 40, "CSRF token should be reasonably long (32 bytes base64 encoded)")+// TestHandleMobileLogin_RedirectURIValidation tests that HandleMobileLogin validates redirect URIs+// TestHandleCallback_CSRFValidation tests that HandleCallback validates CSRF tokens for mobile flow+// TestHandleMobileCallback_RevalidatesRedirectURI tests that handleMobileCallback re-validates the redirect URI+binding1 := generateMobileRedirectBinding(csrfToken, "https://coves.social/app/oauth/callback")+binding3 := generateMobileRedirectBinding("different-csrf-token", "https://coves.social/app/oauth/callback")+assert.NotEqual(t, binding1, binding3, "different CSRF tokens should produce different bindings")+assert.False(t, validateMobileRedirectBinding(attackerCSRF, attackerRedirectURI, userBinding),
+152
internal/atproto/oauth/seal.go
+152
internal/atproto/oauth/seal.go
···
+331
internal/atproto/oauth/seal_test.go
+331
internal/atproto/oauth/seal_test.go
···
+614
internal/atproto/oauth/store.go
+614
internal/atproto/oauth/store.go
···+func (s *PostgresOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {+func (s *PostgresOAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {+func (s *PostgresOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {+func (s *PostgresOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {+func (s *PostgresOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {+if strings.Contains(err.Error(), "duplicate key") && strings.Contains(err.Error(), "oauth_requests_state_key") {+func (w *MobileAwareStoreWrapper) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {+func (w *MobileAwareStoreWrapper) GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) {+// SaveMobileOAuthData implements MobileOAuthStore interface by delegating to underlying store+func (w *MobileAwareStoreWrapper) SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error {+func (s *PostgresOAuthStore) SaveMobileOAuthData(ctx context.Context, state string, data MobileOAuthData) error {+func (s *PostgresOAuthStore) GetMobileOAuthData(ctx context.Context, state string) (*MobileOAuthData, error) {
+522
internal/atproto/oauth/store_test.go
+522
internal/atproto/oauth/store_test.go
···+`, did1.String(), "expired_session", "test.handle", "https://pds.example.com", "https://pds.example.com",
+99
internal/atproto/oauth/transport.go
+99
internal/atproto/oauth/transport.go
···
+132
internal/atproto/oauth/transport_test.go
+132
internal/atproto/oauth/transport_test.go
···
+124
internal/db/migrations/019_update_oauth_for_indigo.sql
+124
internal/db/migrations/019_update_oauth_for_indigo.sql
···+-- Make handle and pds_url nullable too (derived from DID resolution, not always available at auth request time)+-- Note: This will leave the multibase column NULL for now since conversion requires crypto logic+CREATE INDEX idx_oauth_requests_request_uri ON oauth_requests(request_uri) WHERE request_uri IS NOT NULL;+-- Populate session_id for existing sessions (use DID as default for single-session per account)+COMMENT ON COLUMN oauth_sessions.session_id IS 'Session identifier to support multiple concurrent sessions per account';+COMMENT ON CONSTRAINT oauth_sessions_did_session_id_key ON oauth_sessions IS 'Composite key allowing multiple sessions per DID';
+23
internal/db/migrations/020_add_mobile_oauth_csrf.sql
+23
internal/db/migrations/020_add_mobile_oauth_csrf.sql
···+COMMENT ON COLUMN oauth_requests.mobile_csrf_token IS 'CSRF token for mobile OAuth flows, validated against cookie on callback';+COMMENT ON COLUMN oauth_requests.mobile_redirect_uri IS 'Mobile redirect URI (Universal Link) for this OAuth flow';
+137
internal/api/handlers/wellknown/universal_links.go
+137
internal/api/handlers/wellknown/universal_links.go
···+// Spec: https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app+// Debug: keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android+androidFingerprint = "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00"
+25
internal/api/routes/wellknown.go
+25
internal/api/routes/wellknown.go
···
+1
-1
internal/api/handlers/comments/middleware.go
+1
-1
internal/api/handlers/comments/middleware.go
···-func OptionalAuthMiddleware(authMiddleware *middleware.AtProtoAuthMiddleware, next http.HandlerFunc) http.Handler {+func OptionalAuthMiddleware(authMiddleware *middleware.OAuthAuthMiddleware, next http.HandlerFunc) http.Handler {
+164
-312
internal/api/middleware/auth.go
+164
-312
internal/api/middleware/auth.go
······-func NewAtProtoAuthMiddleware(jwksFetcher auth.JWKSFetcher, skipVerify bool) *AtProtoAuthMiddleware {+func NewOAuthAuthMiddleware(unsealer SessionUnsealer, store oauthlib.ClientAuthStore) *OAuthAuthMiddleware {-log.Printf("[AUTH_FAILURE] type=verification_failed ip=%s method=%s path=%s issuer=%s error=%v",-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_FAILURE] type=session_not_found ip=%s method=%s path=%s did=%s session_id=%s error=%v",+log.Printf("[AUTH_FAILURE] type=did_mismatch ip=%s method=%s path=%s token_did=%s session_did=%s",-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, accessToken string) (*auth.DPoPProof, error) {-// HTTP auth schemes are case-insensitive per RFC 7235, so "DPoP", "dpop", "DPOP" are all valid.+// HTTP auth schemes are case-insensitive per RFC 7235, so "Bearer", "bearer", "BEARER" are all valid.···
+511
-728
internal/api/middleware/auth_test.go
+511
-728
internal/api/middleware/auth_test.go
······-func (m *mockJWKSFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {+func (m *mockOAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauthlib.ClientSessionData, error) {+func (m *mockOAuthStore) SaveSession(ctx context.Context, session oauthlib.ClientSessionData) error {+func (m *mockOAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {+func (m *mockOAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauthlib.AuthRequestData, error) {+func (m *mockOAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauthlib.AuthRequestData) error {handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···-// TestRequireAuth_InvalidAuthHeaderFormat tests that non-DPoP tokens are rejected (including Bearer)···-handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+// TestRequireAuth_CaseInsensitiveScheme verifies that Bearer scheme matching is case-insensitive-// TestRequireAuth_CaseInsensitiveScheme verifies that DPoP scheme matching is case-insensitive······handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {···// TestOptionalAuth_InvalidToken tests that OptionalAuth continues without auth on invalid tokenhandler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {······-// TestGetJWTClaims_NotAuthenticated tests that GetJWTClaims returns nil when not authenticated+// TestGetOAuthSession_NotAuthenticated tests that GetOAuthSession returns nil when not authenticated-// TestGetDPoPProof_NotAuthenticated tests that GetDPoPProof returns nil when no DPoP was verified+// TestGetUserAccessToken_NotAuthenticated tests that GetUserAccessToken returns empty when not authenticated-handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+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)+// TestRequireAuth_HeaderPrecedenceOverCookie tests that Authorization header takes precedence over cookie-// Pass a fake access token - ath verification will pass since we don't include ath in the DPoP proof+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {-// Request hits internal service with internal hostname, but X-Forwarded-Host has public hostname+// TestRequireAuth_MissingBothHeaderAndCookie tests that missing both auth methods is rejected+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {-t.Fatalf("expected DPoP verification to succeed with mixed-case/quoted Forwarded header, got %v", err)-dpopProof := createDPoPProofWithAth(t, privateKey, "GET", "https://api.example.com/resource", accessToken)+handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {-dpopProof := createDPoPProofWithAth(t, privateKey, "POST", "https://api.example.com/resource", differentToken)+// TestOptionalAuth_InvalidCookie tests that OptionalAuth continues without auth on invalid cookie-handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {-handler := middleware.OptionalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {-// Helper: createDPoPProofWithAth creates a DPoP proof JWT with ath (access token hash) claim-func createDPoPProofWithAth(t *testing.T, privateKey *ecdsa.PrivateKey, method, uri, accessToken string) string {
+1
-1
internal/api/routes/post.go
+1
-1
internal/api/routes/post.go
···-func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.AtProtoAuthMiddleware) {+func RegisterPostRoutes(r chi.Router, service posts.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+291
tests/e2e/oauth_ratelimit_e2e_test.go
+291
tests/e2e/oauth_ratelimit_e2e_test.go
···+assert.Contains(t, rr.Body.String(), "Rate limit exceeded", "Should have rate limit error message")+assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Mobile login should be rate limited at 10 req/min")+assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Refresh should be rate limited at 20 req/min")+assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Logout should be rate limited at 10 req/min")+assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Callback should be rate limited at 10 req/min")+assert.Less(t, oauthLoginLimit, globalLimit, "OAuth login limit should be stricter than global")+assert.Less(t, oauthRefreshLimit, globalLimit, "OAuth refresh limit should be stricter than global")+assert.Greater(t, oauthRefreshLimit, oauthLoginLimit, "Refresh limit should be higher than login (legitimate use case)")+loginHandler := loginLimiter.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+refreshHandler := refreshLimiter.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-208
tests/integration/jwt_verification_test.go
-208
tests/integration/jwt_verification_test.go
···-verifiedClaims, err := auth.VerifyJWT(httptest.NewRequest("GET", "/", nil).Context(), accessToken, jwksFetcher)-authMiddleware := middleware.NewAtProtoAuthMiddleware(jwksFetcher, true) // skipVerify=true for dev PDS-testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {-testHandler := authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+910
tests/integration/oauth_e2e_test.go
+910
tests/integration/oauth_e2e_test.go
···+// testOAuthComponentsWithMockedSession tests OAuth components that work without PDS redirect flow.+// This is used when testing with localhost PDS, where the indigo library rejects http:// URLs.+func testOAuthComponentsWithMockedSession(t *testing.T, ctx context.Context, _ interface{}, store oauthlib.ClientAuthStore, client *oauth.OAuthClient, userDID, _ string) {+"UPDATE oauth_sessions SET expires_at = NOW() - INTERVAL '1 day' WHERE did = $1 AND session_id = $2",+assert.Equal(t, oauth.ErrSessionNotFound, err, "Should return ErrSessionNotFound for expired session")+assert.Equal(t, authRequest.PKCEVerifier, retrieved.PKCEVerifier, "PKCE verifier should match")+assert.Equal(t, authRequest.AuthServerURL, retrieved.AuthServerURL, "Auth server URL should match")+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized,+// Create the token with a valid DID first, then we'll try to use it with invalid DID in request+validToken, err := client.SealSession(did.String(), initialSession.SessionID, 30*24*time.Hour)+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))+req, err := http.NewRequest("POST", server.URL+"/oauth/refresh", strings.NewReader(string(reqBody)))
+312
tests/integration/oauth_session_fixation_test.go
+312
tests/integration/oauth_session_fixation_test.go
···+// 6. WITH THE FIX: Binding mismatch is detected, mobile cookies cleared, user gets web session+req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)+req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)+req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)+wrongBinding := generateMobileRedirectBindingForTest("different-csrf", "https://coves.social/app/oauth/callback")+req := httptest.NewRequest("GET", "/oauth/callback?code=test&state=test&iss=http://localhost:3001", nil)
+169
tests/integration/oauth_token_verification_test.go
+169
tests/integration/oauth_token_verification_test.go
···+testHandler := e2eAuth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+{"Tampered token", "dGFtcGVyZWQtdG9rZW4tZGF0YQ=="}, // Valid base64 but not a real sealed session+testHandler := e2eAuth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+t.Skip("Session expiration is tested in oauth_e2e_test.go - see TestOAuthE2E_TokenExpiration")
+16
-20
tests/integration/community_e2e_test.go
+16
-20
tests/integration/community_e2e_test.go
·········-routes.RegisterCommunityRoutes(r, communityService, authMiddleware, nil) // nil = allow all community creators+routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators·····················
-73
cmd/genjwks/main.go
-73
cmd/genjwks/main.go
···
-330
internal/atproto/auth/README.md
-330
internal/atproto/auth/README.md
···-This package implements third-party OAuth authentication for Coves, validating DPoP-bound access tokens from mobile apps and other atProto clients.-This is **third-party authentication** (validating incoming requests), not first-party authentication (logging users into Coves web frontend).-2. **JWKS Fetcher** (`jwks_fetcher.go`) - Fetches and caches public keys from PDS authorization servers-3. **Auth Middleware** (`internal/api/middleware/auth.go`) - HTTP middleware that protects endpoints-Public keys are fetched from PDS authorization servers and cached for 1 hour. The cache is automatically cleaned up hourly to remove expired entries.-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)
-122
internal/atproto/auth/did_key_fetcher.go
-122
internal/atproto/auth/did_key_fetcher.go
···-func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {-// Note: secp256k1 is handled separately in FetchPublicKey by returning indigo's PublicKey directly.-return nil, fmt.Errorf("unsupported JWK curve for Go ecdsa: %s (secp256k1 uses indigo)", jwk.Curve)
-1308
internal/atproto/auth/dpop_test.go
-1308
internal/atproto/auth/dpop_test.go
···-func createDPoPProof(t *testing.T, key *testECKey, method, uri string, iat time.Time, jti string) string {-func createES256KDPoPProof(t *testing.T, key *testES256KKey, method, uri string, iat time.Time, jti string) string {
-189
internal/atproto/auth/jwks_fetcher.go
-189
internal/atproto/auth/jwks_fetcher.go
···-func (f *CachedJWKSFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
-496
internal/atproto/auth/jwt_test.go
-496
internal/atproto/auth/jwt_test.go
···-func (m *mockJWKSFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {-func createHS256Token(t *testing.T, subject, issuer, secret string, expiry time.Duration) string {-tokenString := createHS256Token(t, "did:plc:attacker", "https://victim-pds.example.com", "some-secret", 1*time.Hour)-// SECURITY TEST: When no issuers are whitelisted for HS256, all HS256 tokens should be rejected-tokenString := createHS256Token(t, "did:plc:test123", "https://any-pds.example.com", "some-secret", 1*time.Hour)-// Create RS256-signed token (can't actually sign without RSA key, but we can test the header check)-tokenString := createHS256Token(t, "did:plc:test123", "https://test.example.com", "secret", 1*time.Hour)-tokenString := createHS256Token(t, "did:plc:test123", "https://test.example.com", "secret", 1*time.Hour)
+1
docker-compose.prod.yml
+1
docker-compose.prod.yml
+6
-5
internal/api/routes/oauth.go
+6
-5
internal/api/routes/oauth.go
···r.With(corsMiddleware(allowedOrigins), loginLimiter.Middleware).Get("/oauth/callback", handler.HandleCallback)+r.With(loginLimiter.Middleware).Get("/app/oauth/callback", handler.HandleMobileDeepLinkFallback)
+11
static/.well-known/apple-app-site-association
+11
static/.well-known/apple-app-site-association
+10
static/.well-known/assetlinks.json
+10
static/.well-known/assetlinks.json
···+"0B:D8:8C:99:66:25:E5:CD:06:54:80:88:01:6F:B7:38:B9:F4:5B:41:71:F7:95:C8:68:94:87:AD:EA:9F:D9:ED"
+16
-9
internal/atproto/oauth/handlers_test.go
+16
-9
internal/atproto/oauth/handlers_test.go
···
+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
.beads/beads.left.jsonl
+3
.beads/beads.left.jsonl
···+{"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-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":"."}
+1
.beads/beads.left.meta.json
+1
.beads/beads.left.meta.json
···
-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
······
+92
.env.dev.example
+92
.env.dev.example
···
+25
-3
Makefile
+25
-3
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······+mobile-full-setup: verify-stack create-test-account mobile-setup ## Full mobile setup: verify stack, create account, setup ports+@echo "$(GREEN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"+@echo "$(GREEN)โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ$(RESET)"ngrok-up: ## Start ngrok tunnels (for iOS or WiFi testing - requires paid plan for 3 tunnels)
+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) {
+107
-15
internal/atproto/oauth/handlers.go
+107
-15
internal/atproto/oauth/handlers.go
·········+handler.devResolver = NewDevHandleResolver(client.Config.PDSURL, client.Config.AllowPrivateIPs)+slog.Info("dev mode: handle resolution via local PDS enabled", "pds_url", client.Config.PDSURL)+handler.devAuthResolver = NewDevAuthResolver(client.Config.PDSURL, client.Config.AllowPrivateIPs)···+redirectURL, err = h.devAuthResolver.StartDevAuthFlow(ctx, h.client, identifier, h.client.ClientApp.Dir)······+redirectURL, err = h.devAuthResolver.StartDevAuthFlow(mobileCtx, h.client, identifier, h.client.ClientApp.Dir)···// This indicates that bidirectional verification failed (DID->handle->DID roundtrip failed)···
+5
-1
scripts/dev-run.sh
+5
-1
scripts/dev-run.sh
······
+125
internal/atproto/pds/factory.go
+125
internal/atproto/pds/factory.go
···+func NewFromOAuthSession(ctx context.Context, oauthClient *oauth.ClientApp, sessionData *oauth.ClientSessionData) (Client, error) {+func (b *bearerAuth) DoWithAuth(c *http.Client, req *http.Request, _ syntax.NSID) (*http.Response, error) {
+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`
+7
-5
internal/api/routes/communityFeed.go
+7
-5
internal/api/routes/communityFeed.go
······+r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.communityFeed.getCommunity", getCommunityHandler.HandleGetCommunity)
+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)
+1
-1
go.mod
+1
-1
go.mod
···
+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;
+8
internal/core/comments/comment.go
+8
internal/core/comments/comment.go
······
+23
-1
internal/core/comments/interfaces.go
+23
-1
internal/core/comments/interfaces.go
······+// SoftDeleteWithReason performs a soft delete that blanks content but preserves thread structure···+SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error)
+44
-4
internal/core/comments/comment_service_test.go
+44
-4
internal/core/comments/comment_service_test.go
······+func (m *mockCommentRepo) SoftDeleteWithReason(ctx context.Context, uri, reason, deletedByDID string) error {+func (m *mockCommentRepo) SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error) {func (m *mockCommentRepo) ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error) {···+func TestCommentService_buildThreadViews_IncludesDeletedCommentsAsPlaceholders(t *testing.T) {···deletedComment := createTestComment("at://did:plc:commenter123/comment/1", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)normalComment := createTestComment("at://did:plc:commenter123/comment/2", "did:plc:commenter123", "commenter.test", postURI, postURI, 0)···result := service.buildThreadViews(context.Background(), []*Comment{deletedComment, normalComment}, 10, "hot", nil)
-124
docs/PRD_BACKLOG.md
-124
docs/PRD_BACKLOG.md
···-The PDS client (`internal/atproto/pds/client.go`) only has `CreateRecord` but lacks `PutRecord`. This means updates use `CreateRecord` with an existing rkey, which:-PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (uri string, cid string, err error)-func (c *client) PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (string, string, error) {-`UpdateComment` in `internal/core/comments/comment_service.go` uses `CreateRecord` for updates instead of `PutRecord`. This lacks optimistic locking and is semantically incorrect.-// TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.-// PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.-uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID)
+5
-6
internal/core/comments/comment_service.go
+5
-6
internal/core/comments/comment_service.go
···-// TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.-// PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.+uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID)···
+2
-2
cmd/server/main.go
+2
-2
cmd/server/main.go
···log.Println("Timeline XRPC endpoints registered (requires authentication, includes viewer vote state)")+log.Println("Discover XRPC endpoints registered (public with optional auth for viewer vote state)")
+73
internal/api/handlers/common/viewer_state.go
+73
internal/api/handlers/common/viewer_state.go
···+// This allows the helper to work with different feed post types (discover, timeline, communityFeed).
+3
-36
internal/api/handlers/communityFeed/get_community.go
+3
-36
internal/api/handlers/communityFeed/get_community.go
······
+3
-34
internal/api/handlers/timeline/get_timeline.go
+3
-34
internal/api/handlers/timeline/get_timeline.go
······
+9
-4
internal/api/routes/discover.go
+9
-4
internal/api/routes/discover.go
·········+r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.feed.getDiscover", getDiscoverHandler.HandleGetDiscover)
+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
···
+193
-5
tests/integration/discover_test.go
+193
-5
tests/integration/discover_test.go
······+func (m *mockVoteService) CreateVote(_ context.Context, _ *oauthlib.ClientSessionData, _ votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) {+func (m *mockVoteService) DeleteVote(_ context.Context, _ *oauthlib.ClientSessionData, _ votes.DeleteVoteRequest) error {+func (m *mockVoteService) EnsureCachePopulated(_ context.Context, _ *oauthlib.ClientSessionData) error {+func (m *mockVoteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote {···+handler := discover.NewGetDiscoverHandler(discoverService, nil) // nil vote service - tests don't need vote state···+handler := discover.NewGetDiscoverHandler(discoverService, nil) // nil vote service - tests don't need vote state·········req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=100", nil)···+// TestGetDiscover_ViewerVoteState tests that authenticated users see their vote state on posts+communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("votes-%d", testID), fmt.Sprintf("alice-%d.test", testID))+post1URI := createTestPost(t, db, communityDID, "did:plc:author1", "Post with upvote", 10, time.Now().Add(-1*time.Hour))+post2URI := createTestPost(t, db, communityDID, "did:plc:author2", "Post with downvote", 5, time.Now().Add(-2*time.Hour))+_ = createTestPost(t, db, communityDID, "did:plc:author3", "Post without vote", 3, time.Now().Add(-3*time.Hour))+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil)+assert.Contains(t, *feedPost.Post.Viewer.VoteURI, "vote1", "Post1 should have correct vote URI")+// TestGetDiscover_NoViewerStateWithoutAuth tests that unauthenticated users don't get viewer state+communityDID, err := createFeedTestCommunity(db, ctx, fmt.Sprintf("noauth-%d", testID), fmt.Sprintf("alice-%d.test", testID))+mockVotes.AddVote("did:plc:someuser", postURI, "up", "at://did:plc:someuser/social.coves.vote/vote1")+req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getDiscover?sort=new&limit=50", nil)
+11
-11
tests/integration/feed_test.go
+11
-11
tests/integration/feed_test.go
···············req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.communityFeed.getCommunity?community=did:plc:nonexistent&sort=hot&limit=10", nil)··················
+12
internal/atproto/jetstream/comment_consumer.go
+12
internal/atproto/jetstream/comment_consumer.go
·········comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs),