-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)
-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) {
-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)
-616
internal/atproto/auth/dpop.go
-616
internal/atproto/auth/dpop.go
···-func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, 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)-func parseJWKToIndigoPublicKey(jwkMap map[string]interface{}) (indigoCrypto.PublicKey, error) {-// parseJWTHeaderAndClaims manually parses a JWT's header and claims without using golang-jwt.-func parseJWTHeaderAndClaims(tokenString string) (map[string]interface{}, *DPoPClaims, error) {-// This is used instead of golang-jwt for algorithms not supported by golang-jwt (like ES256K).
-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) {
-709
internal/atproto/auth/jwt.go
-709
internal/atproto/auth/jwt.go
···-func VerifyJWT(ctx context.Context, tokenString string, keyFetcher JWKSFetcher) (*Claims, error) {-return nil, fmt.Errorf("expected HS256 for issuer %s but token uses %s", claims.Issuer, header.Alg)-return nil, fmt.Errorf("HS256 not allowed for issuer %s (not in HS256_ISSUERS whitelist)", claims.Issuer)-token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {-func verifyAsymmetricToken(ctx context.Context, tokenString, issuer string, keyFetcher JWKSFetcher) (*Claims, error) {-token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {-// verifyES256KToken verifies a JWT signed with ES256K (secp256k1) using indigo's crypto package.-func verifyES256KToken(ctx context.Context, tokenString, issuer string, keyFetcher JWKSFetcher) (*Claims, error) {-return nil, fmt.Errorf("ES256K verification requires indigo PublicKey or JWK map, got %T", keyData)-func parseJWKMapToIndigoPublicKey(jwkMap map[string]interface{}) (indigoCrypto.PublicKey, error) {-func verifyJWTSignatureWithIndigoKey(tokenString string, pubKey indigoCrypto.PublicKey) error {-return fmt.Errorf("issuer must be HTTPS URL, HTTP URL (dev only), or DID, got: %s", claims.Issuer)
-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)
-4
tests/integration/oauth_helpers.go
-4
tests/integration/oauth_helpers.go
······
+1
docker-compose.prod.yml
+1
docker-compose.prod.yml
+1
-8
Caddyfile
+1
-8
Caddyfile
···
-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'">
+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"
+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'")
-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)
+5
-1
docker-compose.dev.yml
+5
-1
docker-compose.dev.yml
······
+13
-2
internal/atproto/oauth/client.go
+13
-2
internal/atproto/oauth/client.go
············cacheDir := identity.NewCacheDirectory(baseDir, 100_000, time.Hour*24, time.Minute*2, time.Minute*5)+fmt.Printf("๐ OAuth client directory configured with PLC URL: %s (AllowPrivateIPs: %v)\n", config.PLCURL, config.AllowPrivateIPs)
+285
internal/atproto/oauth/dev_auth_resolver.go
+285
internal/atproto/oauth/dev_auth_resolver.go
···+// The standard indigo OAuth resolver requires HTTPS and no port numbers, which breaks local testing.+func (r *DevAuthResolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) {+func (r *DevAuthResolver) ResolveAuthServerMetadataDev(ctx context.Context, serverURL string) (*oauthlib.AuthServerMetadata, error) {+metaURL = fmt.Sprintf("%s://%s:%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname(), u.Port())+metaURL = fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname())+func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {+info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier)
+106
internal/atproto/oauth/dev_resolver.go
+106
internal/atproto/oauth/dev_resolver.go
···+func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {+// ResolveIdentifier attempts to resolve a handle to DID, or returns the DID if already provided+func (r *DevHandleResolver) ResolveIdentifier(ctx context.Context, identifier string) (string, error) {
+41
internal/atproto/oauth/dev_stubs.go
+41
internal/atproto/oauth/dev_stubs.go
···+func (r *DevHandleResolver) ResolveHandle(ctx context.Context, handle string) (string, error) {+func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
+5
-1
scripts/dev-run.sh
+5
-1
scripts/dev-run.sh
······
+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)
+3
-1
internal/api/routes/timeline.go
+3
-1
internal/api/routes/timeline.go
······
+221
internal/core/votes/cache.go
+221
internal/core/votes/cache.go
···+func (c *VoteCache) fetchAllVotesFromPDS(ctx context.Context, pdsClient pds.Client) (map[string]*CachedVote, error) {
+14
internal/core/votes/service.go
+14
internal/core/votes/service.go
···DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req DeleteVoteRequest) error+// Returns from cache if available, otherwise returns nil (caller should ensure cache is populated).
+84
-2
internal/core/votes/service_impl.go
+84
-2
internal/core/votes/service_impl.go
···-func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, logger *slog.Logger) Service {+func NewService(repo Repository, oauthClient *oauthclient.OAuthClient, oauthStore oauth.ClientAuthStore, cache *VoteCache, logger *slog.Logger) Service {···-func NewServiceWithPDSFactory(repo Repository, logger *slog.Logger, factory PDSClientFactory) Service {+func NewServiceWithPDSFactory(repo Repository, cache *VoteCache, logger *slog.Logger, factory PDSClientFactory) Service {············+func (s *voteService) EnsureCachePopulated(ctx context.Context, session *oauth.ClientSessionData) error {+func (s *voteService) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*CachedVote {
+76
-16
internal/atproto/jetstream/vote_consumer.go
+76
-16
internal/atproto/jetstream/vote_consumer.go
······-func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) error {+// Returns (true, nil) if vote was newly inserted, (false, nil) if already existed (idempotent)+func (c *VoteEventConsumer) indexVoteAndUpdateCounts(ctx context.Context, vote *votes.Vote) (bool, error) {···+if err := tx.QueryRowContext(ctx, checkQuery, vote.VoterDID, vote.SubjectURI, vote.URI).Scan(&existingDirection); err != nil && err != sql.ErrNoRows {+if _, err := tx.ExecContext(ctx, softDeleteQuery, vote.VoterDID, vote.SubjectURI, vote.URI); err != nil {+decrementQuery = `UPDATE posts SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE comments SET upvote_count = GREATEST(0, upvote_count - 1), score = upvote_count - 1 - downvote_count WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE posts SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`+decrementQuery = `UPDATE comments SET downvote_count = GREATEST(0, downvote_count - 1), score = upvote_count - (downvote_count - 1) WHERE uri = $1 AND deleted_at IS NULL`+log.Printf("Cleaned up stale vote for %s on %s (was %s)", vote.VoterDID, vote.SubjectURI, existingDirection.String)······log.Printf("Vote subject has unsupported collection: %s (vote indexed, counts not updated)", collection)···
+38
internal/core/comments/types.go
+38
internal/core/comments/types.go
···
+130
internal/api/handlers/comments/create_comment.go
+130
internal/api/handlers/comments/create_comment.go
···+// CreateCommentInput matches the lexicon input schema for social.coves.community.comment.create
+80
internal/api/handlers/comments/delete_comment.go
+80
internal/api/handlers/comments/delete_comment.go
···+// DeleteCommentInput matches the lexicon input schema for social.coves.community.comment.delete
+34
-2
internal/api/handlers/comments/errors.go
+34
-2
internal/api/handlers/comments/errors.go
······+writeError(w, http.StatusBadRequest, "InvalidReply", "The reply reference is invalid or malformed")+writeError(w, http.StatusBadRequest, "ContentTooLong", "Comment content exceeds 10000 graphemes")+writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to perform this action")
+112
internal/api/handlers/comments/update_comment.go
+112
internal/api/handlers/comments/update_comment.go
···+// UpdateCommentInput matches the lexicon input schema for social.coves.community.comment.update
+35
internal/api/routes/comment.go
+35
internal/api/routes/comment.go
···+func RegisterCommentRoutes(r chi.Router, service commentsCore.Service, authMiddleware *middleware.OAuthAuthMiddleware) {
+4
-2
tests/integration/comment_query_test.go
+4
-2
tests/integration/comment_query_test.go
···+// Use factory constructor with nil factory - these tests only use the read path (GetComments)+return comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+// Use factory constructor with nil factory - these tests only use the read path (GetComments)+service := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
+6
-3
tests/integration/comment_vote_test.go
+6
-3
tests/integration/comment_vote_test.go
···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
+2
-1
tests/integration/concurrent_scenarios_test.go
+2
-1
tests/integration/concurrent_scenarios_test.go
···+commentService := comments.NewCommentServiceWithPDSFactory(commentRepo, userRepo, postRepo, communityRepo, nil, nil)
+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;
+17
-13
internal/core/comments/view_models.go
+17
-13
internal/core/comments/view_models.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)
+87
-27
internal/db/postgres/comment_repo.go
+87
-27
internal/db/postgres/comment_repo.go
······+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,······+// SoftDeleteWithReason performs a soft delete that blanks content but preserves thread structure+func (r *postgresCommentRepo) SoftDeleteWithReason(ctx context.Context, uri, reason, deletedByDID string) error {+func (r *postgresCommentRepo) SoftDeleteWithReasonTx(ctx context.Context, tx *sql.Tx, uri, reason, deletedByDID string) (int64, error) {func (r *postgresCommentRepo) ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*comments.Comment, error) {···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···func (r *postgresCommentRepo) ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*comments.Comment, error) {···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···func (r *postgresCommentRepo) ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*comments.Comment, error) {···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,······// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···func (r *postgresCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) {···// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)···+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,···log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,·········log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,···// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)······+&comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy,
+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)···
+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).
+11
-4
internal/api/handlers/discover/get_discover.go
+11
-4
internal/api/handlers/discover/get_discover.go
······+func NewGetDiscoverHandler(service discover.Service, voteService votes.Service) *GetDiscoverHandler {···
+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)··················
+7
-7
tests/integration/timeline_test.go
+7
-7
tests/integration/timeline_test.go
···············req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.feed.getTimeline?sort=new&limit=10", nil)······
+1
-1
tests/integration/user_journey_e2e_test.go
+1
-1
tests/integration/user_journey_e2e_test.go
···routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators