A community based topic aggregation platform built on atproto

Compare changes

Choose any two refs to compare.

Changed files
+5651 -449
.beads
aggregators
cmd
server
internal
scripts
tests
+79
internal/core/blueskypost/fetcher_test.go
··· 601 601 t.Error("Expected no quoted post with nil embeds") 602 602 } 603 603 } 604 + 605 + func TestMapAPIPostToResult_ExternalEmbed(t *testing.T) { 606 + // Test that external link embeds are correctly extracted 607 + apiPost := &blueskyAPIPost{ 608 + URI: "at://did:plc:test/app.bsky.feed.post/test", 609 + CID: "bafyreiabc123", 610 + Author: blueskyAPIAuthor{ 611 + DID: "did:plc:test", 612 + Handle: "english.lemonde.fr", 613 + DisplayName: "Le Monde", 614 + }, 615 + Record: blueskyAPIRecord{ 616 + Text: "Check out this article", 617 + CreatedAt: "2025-12-21T10:30:00Z", 618 + }, 619 + Embed: &blueskyAPIEmbed{ 620 + Type: "app.bsky.embed.external#view", 621 + External: &blueskyAPIExternal{ 622 + URI: "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html", 623 + Title: "NBA and Fiba announce search for teams", 624 + Description: "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league.", 625 + Thumb: "https://cdn.lemonde.fr/thumbnail.jpg", 626 + }, 627 + }, 628 + ReplyCount: 10, 629 + RepostCount: 5, 630 + LikeCount: 100, 631 + } 632 + 633 + result := mapAPIPostToResult(apiPost) 634 + 635 + // Verify basic fields 636 + if result.URI != "at://did:plc:test/app.bsky.feed.post/test" { 637 + t.Errorf("Expected URI 'at://did:plc:test/app.bsky.feed.post/test', got %s", result.URI) 638 + } 639 + if result.Author.Handle != "english.lemonde.fr" { 640 + t.Errorf("Expected Handle 'english.lemonde.fr', got %s", result.Author.Handle) 641 + } 642 + 643 + // Verify external embed is extracted 644 + if result.Embed == nil { 645 + t.Fatal("Expected Embed to be set for external link post") 646 + } 647 + if result.Embed.URI != "https://www.lemonde.fr/en/international/article/2025/12/22/nba-article.html" { 648 + t.Errorf("Expected external URI, got %s", result.Embed.URI) 649 + } 650 + if result.Embed.Title != "NBA and Fiba announce search for teams" { 651 + t.Errorf("Expected external title, got %s", result.Embed.Title) 652 + } 653 + if result.Embed.Description != "The NBA and FIBA have announced a joint search for teams interested in joining a potential European league." { 654 + t.Errorf("Expected external description, got %s", result.Embed.Description) 655 + } 656 + if result.Embed.Thumb != "https://cdn.lemonde.fr/thumbnail.jpg" { 657 + t.Errorf("Expected external thumb, got %s", result.Embed.Thumb) 658 + } 659 + } 660 + 661 + func TestMapAPIPostToResult_ExternalEmbedNil(t *testing.T) { 662 + // Test that posts without external embeds don't have Embed set 663 + apiPost := &blueskyAPIPost{ 664 + URI: "at://did:plc:test/app.bsky.feed.post/test", 665 + CID: "bafyreiabc123", 666 + Author: blueskyAPIAuthor{ 667 + DID: "did:plc:test", 668 + Handle: "user.bsky.social", 669 + }, 670 + Record: blueskyAPIRecord{ 671 + Text: "Just a regular post without links", 672 + CreatedAt: "2025-12-21T10:30:00Z", 673 + }, 674 + Embed: nil, 675 + } 676 + 677 + result := mapAPIPostToResult(apiPost) 678 + 679 + if result.Embed != nil { 680 + t.Errorf("Expected Embed to be nil for post without external embed, got %+v", result.Embed) 681 + } 682 + }
+20
internal/core/blueskypost/types.go
··· 54 54 55 55 // Unavailable indicates the post could not be resolved (deleted, private, blocked, etc.) 56 56 Unavailable bool `json:"unavailable"` 57 + 58 + // Embed contains the post's external link embed, if present 59 + // This captures link cards from the original Bluesky post 60 + Embed *ExternalEmbed `json:"embed,omitempty"` 57 61 } 58 62 59 63 // Author represents a Bluesky post author's identity. ··· 70 74 // Avatar is the URL to the user's avatar image (may be empty) 71 75 Avatar string `json:"avatar,omitempty"` 72 76 } 77 + 78 + // ExternalEmbed represents an external link embed from a Bluesky post. 79 + // This captures link cards (URLs with title, description, and thumbnail). 80 + type ExternalEmbed struct { 81 + // URI is the URL of the external link 82 + URI string `json:"uri"` 83 + 84 + // Title is the page title (from og:title or <title>) 85 + Title string `json:"title,omitempty"` 86 + 87 + // Description is the page description (from og:description or meta description) 88 + Description string `json:"description,omitempty"` 89 + 90 + // Thumb is the URL to the thumbnail image (from og:image) 91 + Thumb string `json:"thumb,omitempty"` 92 + }
+24 -7
tests/integration/bluesky_post_test.go
··· 4 4 "Coves/internal/atproto/identity" 5 5 "Coves/internal/core/blueskypost" 6 6 "context" 7 + "database/sql" 7 8 "fmt" 8 9 "net/http" 9 10 "testing" ··· 21 22 // 22 23 // Use this for tests that need to resolve real Bluesky handles like "ianboudreau.com". 23 24 // Do NOT use for tests involving local Coves identities (use local PLC instead). 24 - func productionPLCIdentityResolver() identity.Resolver { 25 + // 26 + // NOTE: Requires a database connection for the identity cache. Pass the test db. 27 + func productionPLCIdentityResolver(db *sql.DB) identity.Resolver { 25 28 config := identity.DefaultConfig() 26 29 config.PLCURL = "https://plc.directory" // Production PLC - READ ONLY 27 - return identity.NewResolver(nil, config) 30 + return identity.NewResolver(db, config) 28 31 } 29 32 30 33 // TestBlueskyPostCrossPosting_URLParsing tests URL detection and parsing ··· 37 40 defer func() { _ = db.Close() }() 38 41 39 42 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 40 - identityResolver := productionPLCIdentityResolver() 43 + identityResolver := productionPLCIdentityResolver(db) 41 44 42 45 // Setup Bluesky post service 43 46 repo := blueskypost.NewRepository(db) ··· 119 122 _, _ = db.Exec("DELETE FROM bluesky_post_cache") 120 123 121 124 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 122 - identityResolver := productionPLCIdentityResolver() 125 + identityResolver := productionPLCIdentityResolver(db) 123 126 124 127 repo := blueskypost.NewRepository(db) 125 128 service := blueskypost.NewService(repo, identityResolver, ··· 239 242 assert.Equal(t, "davidpfau.com", result.Author.Handle) 240 243 assert.NotEmpty(t, result.Text) 241 244 245 + // Verify external embed is extracted 246 + if result.Embed != nil { 247 + assert.NotEmpty(t, result.Embed.URI, "External embed should have URI") 248 + t.Logf(" External embed URI: %s", result.Embed.URI) 249 + if result.Embed.Title != "" { 250 + t.Logf(" External embed title: %s", result.Embed.Title) 251 + } 252 + if result.Embed.Thumb != "" { 253 + t.Logf(" External embed thumb: %s", result.Embed.Thumb) 254 + } 255 + } else { 256 + t.Log(" Note: No external embed found (post may have been modified)") 257 + } 258 + 242 259 t.Logf("โœ“ Successfully fetched post with link embed:") 243 260 t.Logf(" Author: @%s", result.Author.Handle) 244 261 t.Logf(" Text: %.80s...", result.Text) ··· 385 402 defer func() { _ = db.Close() }() 386 403 387 404 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 388 - identityResolver := productionPLCIdentityResolver() 405 + identityResolver := productionPLCIdentityResolver(db) 389 406 390 407 repo := blueskypost.NewRepository(db) 391 408 service := blueskypost.NewService(repo, identityResolver, ··· 542 559 _, _ = db.Exec("DELETE FROM bluesky_post_cache WHERE at_uri LIKE 'at://did:plc:%'") 543 560 544 561 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 545 - identityResolver := productionPLCIdentityResolver() 562 + identityResolver := productionPLCIdentityResolver(db) 546 563 547 564 repo := blueskypost.NewRepository(db) 548 565 service := blueskypost.NewService(repo, identityResolver, ··· 611 628 _, _ = db.Exec("DELETE FROM bluesky_post_cache") 612 629 613 630 // Use production PLC resolver for real Bluesky handles (READ-ONLY) 614 - identityResolver := productionPLCIdentityResolver() 631 + identityResolver := productionPLCIdentityResolver(db) 615 632 616 633 // Setup Bluesky post service 617 634 repo := blueskypost.NewRepository(db)
+7 -5
internal/core/communities/token_refresh.go
··· 13 13 // refreshPDSToken exchanges a refresh token for new access and refresh tokens 14 14 // Uses com.atproto.server.refreshSession endpoint via Indigo SDK 15 15 // CRITICAL: Refresh tokens are single-use - old refresh token is revoked on success 16 - func refreshPDSToken(ctx context.Context, pdsURL, currentAccessToken, refreshToken string) (newAccessToken, newRefreshToken string, err error) { 16 + func refreshPDSToken(ctx context.Context, pdsURL, refreshToken string) (newAccessToken, newRefreshToken string, err error) { 17 17 if pdsURL == "" { 18 18 return "", "", fmt.Errorf("PDS URL is required") 19 19 } ··· 21 21 return "", "", fmt.Errorf("refresh token is required") 22 22 } 23 23 24 - // Create XRPC client with auth credentials 25 - // The refresh endpoint requires authentication with the refresh token 24 + // Create XRPC client with refresh token as the auth credential 25 + // IMPORTANT: The xrpc client always sends AccessJwt as the Authorization header, 26 + // but refreshSession requires the refresh token in that header. 27 + // So we put the refresh token in AccessJwt to make it work correctly. 26 28 client := &xrpc.Client{ 27 29 Host: pdsURL, 28 30 Auth: &xrpc.AuthInfo{ 29 - AccessJwt: currentAccessToken, // Can be expired (not used for refresh auth) 30 - RefreshJwt: refreshToken, // This is what authenticates the refresh request 31 + AccessJwt: refreshToken, // Refresh token goes here (sent as Authorization header) 32 + RefreshJwt: refreshToken, // Also set here for completeness 31 33 }, 32 34 } 33 35
+1 -1
.beads/issues.jsonl
··· 6 6 {"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":"."} 7 7 {"id":"Coves-iw5","content_hash":"d3379c617b7583f6b88a0523b3cdd1e4415176877ab00b48710819f2484c4856","title":"Apply functional options pattern to NewGetCommunityHandler","description":"Location: internal/api/handlers/communityFeed/get.go\n\nApply functional options pattern for optional dependencies (votes, bluesky).\n\nDepends on: Coves-jdf (NewPostService refactor should be done first to establish pattern)\nParent: Coves-8k1","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T21:35:27.369297201-08:00","updated_at":"2025-12-22T21:35:58.115771178-08:00","source_repo":"."} 8 8 {"id":"Coves-jdf","content_hash":"cb27689d71f44fd555e29d2988f2ad053efb6c565cd4f803ff68eaade59c7546","title":"Apply functional options pattern to NewPostService","description":"Location: internal/core/posts/service.go\n\nCurrent constructor (7 params, 4 optional):\n```go\nfunc NewPostService(repo Repository, communityService communities.Service, aggregatorService aggregators.Service, blobService blobs.Service, unfurlService unfurl.Service, blueskyService blueskypost.Service, pdsURL string) Service\n```\n\nRefactor to:\n```go\ntype Option func(*postService)\n\nfunc WithAggregatorService(svc aggregators.Service) Option\nfunc WithBlobService(svc blobs.Service) Option\nfunc WithUnfurlService(svc unfurl.Service) Option\nfunc WithBlueskyService(svc blueskypost.Service) Option\n\nfunc NewPostService(repo Repository, communityService communities.Service, pdsURL string, opts ...Option) Service\n```\n\nFiles to update:\n- internal/core/posts/service.go (define Option type and With* functions)\n- cmd/server/main.go (production caller)\n- ~15 test files with call sites\n\nStart with this one as it has the most params and is most impacted.\nParent: Coves-8k1","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-22T21:35:27.264325344-08:00","updated_at":"2025-12-22T21:35:58.003863381-08:00","source_repo":"."} 9 - {"id":"Coves-p44","content_hash":"6f12091f6e5f1ad9812f8da4ecd720e0f9df1afd1fdb593b3e52c32be0193d94","title":"Bluesky embed conversion Phase 2: resolve post and populate CID","description":"When converting a Bluesky URL to a social.coves.embed.post, we need to:\n\n1. Call blueskyService.ResolvePost() to get the full post data including CID\n2. Populate both URI and CID in the strongRef\n3. Consider caching/re-using resolved post data for rendering\n\nCurrently disabled in Phase 1 (text-only) because:\n- social.coves.embed.post requires a valid CID in com.atproto.repo.strongRef\n- Empty CID causes PDS to reject the record creation\n\nRelated files:\n- internal/core/posts/service.go:tryConvertBlueskyURLToPostEmbed()\n- internal/atproto/lexicon/social/coves/embed/post.json\n\nThis is part of the Bluesky post cross-posting feature (images/embeds phase).","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-22T21:25:23.540135876-08:00","updated_at":"2025-12-22T21:25:41.704980685-08:00","source_repo":"."} 9 + {"id":"Coves-p44","content_hash":"6f12091f6e5f1ad9812f8da4ecd720e0f9df1afd1fdb593b3e52c32be0193d94","title":"Bluesky embed conversion Phase 2: resolve post and populate CID","description":"When converting a Bluesky URL to a social.coves.embed.post, we need to:\n\n1. Call blueskyService.ResolvePost() to get the full post data including CID\n2. Populate both URI and CID in the strongRef\n3. Consider caching/re-using resolved post data for rendering\n\nCurrently disabled in Phase 1 (text-only) because:\n- social.coves.embed.post requires a valid CID in com.atproto.repo.strongRef\n- Empty CID causes PDS to reject the record creation\n\nRelated files:\n- internal/core/posts/service.go:tryConvertBlueskyURLToPostEmbed()\n- internal/atproto/lexicon/social/coves/embed/post.json\n\nThis is part of the Bluesky post cross-posting feature (images/embeds phase).","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-12-22T21:25:23.540135876-08:00","updated_at":"2025-12-23T14:41:49.014541876-08:00","closed_at":"2025-12-23T14:41:49.014541876-08:00","source_repo":"."} 10 10 {"id":"Coves-r6n","content_hash":"48a9b995bdef6efcfa2c42d5620cc262b264d3dfe7c265423aaed7ee8890a2f2","title":"Community handle names limited to 16 characters due to PDS hardcoded limit","description":"## Summary\nThe Bluesky PDS has a hardcoded 18-character limit on the first segment of handles (packages/pds/src/handle/index.ts line 89). With our `c-` prefix for community handles, this limits community names to 16 characters.\n\n## Affected Names\nNames like `artificial-intelligence` (23 chars), `software-engineering` (20 chars), or `explain-like-im-five` (20 chars) won't work.\n\n## Background\n- AT Protocol spec allows 63 chars per segment (DNS label limit)\n- PDS limit is a Bluesky policy choice for `*.bsky.social` usability\n- Fix was discussed in https://github.com/bluesky-social/atproto/issues/2391\n- PR https://github.com/bluesky-social/atproto/pull/2392 changed from 30 total to 18 first-segment\n\n## Resolution Options\n1. **Fork PDS** - Change `if (front.length \u003e 18)` to higher limit (e.g., 30 or 63)\n2. **Accept limit** - Document 16-char max for community names\n3. **Remove c- prefix** - Gains 2 chars but loses user/community distinction\n\n## Decision\nAccepting the limit for now. Most community names fit. Revisit if user demand arises.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-22T22:06:34.355515838-08:00","updated_at":"2025-12-22T22:06:49.297076332-08:00","source_repo":"."}
+77 -7
internal/api/middleware/auth.go
··· 33 33 const ( 34 34 AuthMethodOAuth = "oauth" 35 35 AuthMethodServiceJWT = "service_jwt" 36 + AuthMethodAPIKey = "api_key" 36 37 ) 37 38 39 + // API key prefix constant 40 + const APIKeyPrefix = "ckapi_" 41 + 38 42 // SessionUnsealer is an interface for unsealing session tokens 39 43 // This allows for mocking in tests 40 44 type SessionUnsealer interface { ··· 51 55 Validate(ctx context.Context, tokenString string, lexMethod *syntax.NSID) (syntax.DID, error) 52 56 } 53 57 58 + // APIKeyValidator is an interface for validating API keys (used by aggregators) 59 + type APIKeyValidator interface { 60 + // ValidateKey validates an API key and returns the aggregator DID if valid 61 + ValidateKey(ctx context.Context, plainKey string) (aggregatorDID string, err error) 62 + // RefreshTokensIfNeeded refreshes OAuth tokens for the aggregator if they are expired 63 + RefreshTokensIfNeeded(ctx context.Context, aggregatorDID string) error 64 + } 65 + 54 66 // OAuthAuthMiddleware enforces OAuth authentication using sealed session tokens. 55 67 type OAuthAuthMiddleware struct { 56 68 unsealer SessionUnsealer ··· 329 341 } 330 342 } 331 343 332 - // DualAuthMiddleware enforces authentication using either OAuth sealed tokens (for users) 333 - // or PDS service JWTs (for aggregators only). 344 + // DualAuthMiddleware enforces authentication using either OAuth sealed tokens (for users), 345 + // PDS service JWTs (for aggregators), or API keys (for aggregators). 334 346 type DualAuthMiddleware struct { 335 347 unsealer SessionUnsealer 336 348 store oauthlib.ClientAuthStore 337 349 serviceValidator ServiceAuthValidator 338 350 aggregatorChecker AggregatorChecker 351 + apiKeyValidator APIKeyValidator // Optional: if nil, API key auth is disabled 339 352 } 340 353 341 354 // NewDualAuthMiddleware creates a new dual auth middleware that supports both OAuth and service JWT authentication. ··· 353 366 } 354 367 } 355 368 356 - // RequireAuth middleware ensures the user is authenticated via either OAuth or service JWT. 369 + // WithAPIKeyValidator adds API key validation support to the middleware. 370 + // Returns the middleware for method chaining. 371 + func (m *DualAuthMiddleware) WithAPIKeyValidator(validator APIKeyValidator) *DualAuthMiddleware { 372 + m.apiKeyValidator = validator 373 + return m 374 + } 375 + 376 + // RequireAuth middleware ensures the user is authenticated via either OAuth, service JWT, or API key. 357 377 // Supports: 378 + // - API keys via Authorization: Bearer ckapi_... (aggregators only, checked first) 358 379 // - OAuth sealed session tokens via Authorization: Bearer <sealed_token> or Cookie: coves_session=<sealed_token> 359 380 // - Service JWTs via Authorization: Bearer <jwt> 360 381 // 361 - // SECURITY: Service JWT authentication is RESTRICTED to registered aggregators only. 362 - // Non-aggregator DIDs will be rejected even with valid JWT signatures. 363 - // This enforcement happens in handleServiceAuth() via aggregatorChecker.IsAggregator(). 382 + // SECURITY: Service JWT and API key authentication are RESTRICTED to registered aggregators only. 383 + // Non-aggregator DIDs will be rejected even with valid JWT signatures or API keys. 384 + // This enforcement happens in handleServiceAuth() via aggregatorChecker.IsAggregator() and 385 + // in handleAPIKeyAuth() via apiKeyValidator.ValidateKey(). 364 386 // 365 387 // If not authenticated, returns 401. 366 388 // If authenticated, injects user DID and auth method into context. ··· 398 420 log.Printf("[AUTH_TRACE] ip=%s method=%s path=%s token_source=%s", 399 421 r.RemoteAddr, r.Method, r.URL.Path, tokenSource) 400 422 423 + // Check for API key first (before JWT/OAuth routing) 424 + // API keys start with "ckapi_" prefix 425 + if strings.HasPrefix(token, APIKeyPrefix) { 426 + m.handleAPIKeyAuth(w, r, next, token) 427 + return 428 + } 429 + 401 430 // Detect token type and route to appropriate handler 402 431 if isJWTFormat(token) { 403 432 m.handleServiceAuth(w, r, next, token) ··· 411 440 func (m *DualAuthMiddleware) handleServiceAuth(w http.ResponseWriter, r *http.Request, next http.Handler, token string) { 412 441 // Validate the service JWT 413 442 // Note: lexMethod is nil, which allows any lexicon method (endpoint-agnostic validation). 414 - // The ServiceAuthValidator skips the lexicon method check when nil (see indigo/atproto/auth/jwt.go:86-88). 443 + // The ServiceAuthValidator skips the lexicon method check when lexMethod is nil. 415 444 // This is intentional - we want aggregators to authenticate globally, not per-endpoint. 416 445 did, err := m.serviceValidator.Validate(r.Context(), token, nil) 417 446 if err != nil { ··· 452 481 next.ServeHTTP(w, r.WithContext(ctx)) 453 482 } 454 483 484 + // handleAPIKeyAuth handles authentication using Coves API keys (aggregators only) 485 + func (m *DualAuthMiddleware) handleAPIKeyAuth(w http.ResponseWriter, r *http.Request, next http.Handler, token string) { 486 + // Check if API key validation is enabled 487 + if m.apiKeyValidator == nil { 488 + log.Printf("[AUTH_FAILURE] type=api_key_disabled ip=%s method=%s path=%s", 489 + r.RemoteAddr, r.Method, r.URL.Path) 490 + writeAuthError(w, "API key authentication is not enabled") 491 + return 492 + } 493 + 494 + // Validate the API key 495 + aggregatorDID, err := m.apiKeyValidator.ValidateKey(r.Context(), token) 496 + if err != nil { 497 + log.Printf("[AUTH_FAILURE] type=api_key_invalid ip=%s method=%s path=%s error=%v", 498 + r.RemoteAddr, r.Method, r.URL.Path, err) 499 + writeAuthError(w, "Invalid or revoked API key") 500 + return 501 + } 502 + 503 + // Refresh OAuth tokens if needed (for PDS operations) 504 + if err := m.apiKeyValidator.RefreshTokensIfNeeded(r.Context(), aggregatorDID); err != nil { 505 + log.Printf("[AUTH_FAILURE] type=token_refresh_failed ip=%s method=%s path=%s did=%s error=%v", 506 + r.RemoteAddr, r.Method, r.URL.Path, aggregatorDID, err) 507 + // Token refresh failure means the aggregator cannot perform authenticated PDS operations 508 + // This is a critical failure - reject the request so the aggregator knows to re-authenticate 509 + writeAuthError(w, "API key authentication failed: unable to refresh OAuth tokens. Please re-authenticate.") 510 + return 511 + } 512 + 513 + log.Printf("[AUTH_SUCCESS] type=api_key ip=%s method=%s path=%s did=%s", 514 + r.RemoteAddr, r.Method, r.URL.Path, aggregatorDID) 515 + 516 + // Inject DID and auth method into context 517 + ctx := context.WithValue(r.Context(), UserDIDKey, aggregatorDID) 518 + ctx = context.WithValue(ctx, IsAggregatorAuthKey, true) 519 + ctx = context.WithValue(ctx, AuthMethodKey, AuthMethodAPIKey) 520 + 521 + // Call next handler 522 + next.ServeHTTP(w, r.WithContext(ctx)) 523 + } 524 + 455 525 // handleOAuthAuth handles authentication using OAuth sealed session tokens (existing logic) 456 526 func (m *DualAuthMiddleware) handleOAuthAuth(w http.ResponseWriter, r *http.Request, next http.Handler, token string) { 457 527 // Authenticate using sealed token
+206
internal/api/middleware/auth_test.go
··· 1691 1691 }) 1692 1692 } 1693 1693 } 1694 + 1695 + // Mock APIKeyValidator for testing 1696 + type mockAPIKeyValidator struct { 1697 + aggregators map[string]string // key -> DID 1698 + shouldFail bool 1699 + refreshCalled bool 1700 + } 1701 + 1702 + func (m *mockAPIKeyValidator) ValidateKey(ctx context.Context, plainKey string) (string, error) { 1703 + if m.shouldFail { 1704 + return "", fmt.Errorf("invalid API key") 1705 + } 1706 + // Extract DID from key for testing (real implementation would hash and look up) 1707 + // Test format: ckapi_<did_suffix>_rest 1708 + if len(plainKey) < 12 { 1709 + return "", fmt.Errorf("invalid key format") 1710 + } 1711 + // For testing, assume valid keys return a known aggregator DID 1712 + if aggregatorDID, ok := m.aggregators[plainKey]; ok { 1713 + return aggregatorDID, nil 1714 + } 1715 + return "", fmt.Errorf("unknown API key") 1716 + } 1717 + 1718 + func (m *mockAPIKeyValidator) RefreshTokensIfNeeded(ctx context.Context, aggregatorDID string) error { 1719 + m.refreshCalled = true 1720 + return nil 1721 + } 1722 + 1723 + // TestDualAuthMiddleware_APIKey_Valid tests API key authentication 1724 + func TestDualAuthMiddleware_APIKey_Valid(t *testing.T) { 1725 + client := newMockOAuthClient() 1726 + store := newMockOAuthStore() 1727 + validator := &mockServiceAuthValidator{} 1728 + aggregatorChecker := &mockAggregatorChecker{ 1729 + aggregators: make(map[string]bool), 1730 + } 1731 + 1732 + apiKeyValidator := &mockAPIKeyValidator{ 1733 + aggregators: map[string]string{ 1734 + "ckapi_test1234567890123456789012345678": "did:plc:aggregator123", 1735 + }, 1736 + } 1737 + 1738 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker). 1739 + WithAPIKeyValidator(apiKeyValidator) 1740 + 1741 + handlerCalled := false 1742 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1743 + handlerCalled = true 1744 + 1745 + // Verify DID was extracted 1746 + extractedDID := GetUserDID(r) 1747 + if extractedDID != "did:plc:aggregator123" { 1748 + t.Errorf("expected DID 'did:plc:aggregator123', got %s", extractedDID) 1749 + } 1750 + 1751 + // Verify it's marked as aggregator auth 1752 + if !IsAggregatorAuth(r) { 1753 + t.Error("expected IsAggregatorAuth to be true") 1754 + } 1755 + 1756 + // Verify auth method 1757 + authMethod := GetAuthMethod(r) 1758 + if authMethod != AuthMethodAPIKey { 1759 + t.Errorf("expected auth method %s, got %s", AuthMethodAPIKey, authMethod) 1760 + } 1761 + 1762 + w.WriteHeader(http.StatusOK) 1763 + })) 1764 + 1765 + req := httptest.NewRequest("GET", "/test", nil) 1766 + req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678") 1767 + w := httptest.NewRecorder() 1768 + 1769 + handler.ServeHTTP(w, req) 1770 + 1771 + if !handlerCalled { 1772 + t.Error("handler was not called") 1773 + } 1774 + 1775 + if w.Code != http.StatusOK { 1776 + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) 1777 + } 1778 + 1779 + // Verify token refresh was attempted 1780 + if !apiKeyValidator.refreshCalled { 1781 + t.Error("expected token refresh to be called") 1782 + } 1783 + } 1784 + 1785 + // TestDualAuthMiddleware_APIKey_Invalid tests API key authentication with invalid key 1786 + func TestDualAuthMiddleware_APIKey_Invalid(t *testing.T) { 1787 + client := newMockOAuthClient() 1788 + store := newMockOAuthStore() 1789 + validator := &mockServiceAuthValidator{} 1790 + aggregatorChecker := &mockAggregatorChecker{ 1791 + aggregators: make(map[string]bool), 1792 + } 1793 + 1794 + apiKeyValidator := &mockAPIKeyValidator{ 1795 + shouldFail: true, 1796 + } 1797 + 1798 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker). 1799 + WithAPIKeyValidator(apiKeyValidator) 1800 + 1801 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1802 + t.Error("handler should not be called for invalid API key") 1803 + })) 1804 + 1805 + req := httptest.NewRequest("GET", "/test", nil) 1806 + req.Header.Set("Authorization", "Bearer ckapi_invalid_key_12345678901234567") 1807 + w := httptest.NewRecorder() 1808 + 1809 + handler.ServeHTTP(w, req) 1810 + 1811 + if w.Code != http.StatusUnauthorized { 1812 + t.Errorf("expected status 401, got %d", w.Code) 1813 + } 1814 + 1815 + var response map[string]string 1816 + _ = json.Unmarshal(w.Body.Bytes(), &response) 1817 + if response["message"] != "Invalid or revoked API key" { 1818 + t.Errorf("unexpected error message: %s", response["message"]) 1819 + } 1820 + } 1821 + 1822 + // TestDualAuthMiddleware_APIKey_Disabled tests API key auth when validator is not configured 1823 + func TestDualAuthMiddleware_APIKey_Disabled(t *testing.T) { 1824 + client := newMockOAuthClient() 1825 + store := newMockOAuthStore() 1826 + validator := &mockServiceAuthValidator{} 1827 + aggregatorChecker := &mockAggregatorChecker{ 1828 + aggregators: make(map[string]bool), 1829 + } 1830 + 1831 + // No API key validator configured 1832 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker) 1833 + 1834 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1835 + t.Error("handler should not be called when API key auth is disabled") 1836 + })) 1837 + 1838 + req := httptest.NewRequest("GET", "/test", nil) 1839 + req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678") 1840 + w := httptest.NewRecorder() 1841 + 1842 + handler.ServeHTTP(w, req) 1843 + 1844 + if w.Code != http.StatusUnauthorized { 1845 + t.Errorf("expected status 401, got %d", w.Code) 1846 + } 1847 + 1848 + var response map[string]string 1849 + _ = json.Unmarshal(w.Body.Bytes(), &response) 1850 + if response["message"] != "API key authentication is not enabled" { 1851 + t.Errorf("unexpected error message: %s", response["message"]) 1852 + } 1853 + } 1854 + 1855 + // TestDualAuthMiddleware_APIKey_PrecedenceOverOAuth tests that API keys are detected before OAuth 1856 + func TestDualAuthMiddleware_APIKey_PrecedenceOverOAuth(t *testing.T) { 1857 + client := newMockOAuthClient() 1858 + store := newMockOAuthStore() 1859 + validator := &mockServiceAuthValidator{} 1860 + aggregatorChecker := &mockAggregatorChecker{ 1861 + aggregators: make(map[string]bool), 1862 + } 1863 + 1864 + apiKeyValidator := &mockAPIKeyValidator{ 1865 + aggregators: map[string]string{ 1866 + "ckapi_test1234567890123456789012345678": "did:plc:apikey_aggregator", 1867 + }, 1868 + } 1869 + 1870 + middleware := NewDualAuthMiddleware(client, store, validator, aggregatorChecker). 1871 + WithAPIKeyValidator(apiKeyValidator) 1872 + 1873 + handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1874 + // Verify API key auth was used 1875 + authMethod := GetAuthMethod(r) 1876 + if authMethod != AuthMethodAPIKey { 1877 + t.Errorf("expected API key auth method, got %s", authMethod) 1878 + } 1879 + 1880 + // Verify DID from API key (not OAuth) 1881 + did := GetUserDID(r) 1882 + if did != "did:plc:apikey_aggregator" { 1883 + t.Errorf("expected API key aggregator DID, got %s", did) 1884 + } 1885 + 1886 + w.WriteHeader(http.StatusOK) 1887 + })) 1888 + 1889 + // Use API key format token (starts with ckapi_) 1890 + req := httptest.NewRequest("GET", "/test", nil) 1891 + req.Header.Set("Authorization", "Bearer ckapi_test1234567890123456789012345678") 1892 + w := httptest.NewRecorder() 1893 + 1894 + handler.ServeHTTP(w, req) 1895 + 1896 + if w.Code != http.StatusOK { 1897 + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) 1898 + } 1899 + }
+63
internal/atproto/lexicon/social/coves/aggregator/createApiKey.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.aggregator.createApiKey", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create an API key for the authenticated aggregator. Requires OAuth authentication. The API key is returned ONCE and cannot be retrieved again. Store it securely.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "description": "No input required. The key is generated server-side for the authenticated aggregator.", 13 + "properties": {} 14 + } 15 + }, 16 + "output": { 17 + "encoding": "application/json", 18 + "schema": { 19 + "type": "object", 20 + "required": ["key", "keyPrefix", "did", "createdAt"], 21 + "properties": { 22 + "key": { 23 + "type": "string", 24 + "description": "The plain-text API key. This is shown ONCE and cannot be retrieved again. Format: ckapi_<64-hex-chars> (32 bytes hex-encoded)" 25 + }, 26 + "keyPrefix": { 27 + "type": "string", 28 + "description": "First 12 characters of the key (e.g., 'ckapi_ab12cd') for identification in logs and UI" 29 + }, 30 + "did": { 31 + "type": "string", 32 + "format": "did", 33 + "description": "DID of the aggregator that owns this key" 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "ISO8601 timestamp when the key was created" 39 + } 40 + } 41 + } 42 + }, 43 + "errors": [ 44 + { 45 + "name": "AuthenticationRequired", 46 + "description": "OAuth authentication is required to create an API key" 47 + }, 48 + { 49 + "name": "OAuthSessionRequired", 50 + "description": "OAuth session is required (not service JWT) to create an API key" 51 + }, 52 + { 53 + "name": "AggregatorRequired", 54 + "description": "Only registered aggregators can create API keys" 55 + }, 56 + { 57 + "name": "KeyGenerationFailed", 58 + "description": "Failed to generate the API key" 59 + } 60 + ] 61 + } 62 + } 63 + }
+30
internal/atproto/lexicon/social/coves/aggregator/defs.json
··· 204 204 "format": "at-uri" 205 205 } 206 206 } 207 + }, 208 + "apiKeyView": { 209 + "type": "object", 210 + "description": "View of an API key's metadata. The actual key value is never returned after initial creation.", 211 + "required": ["prefix", "createdAt", "isRevoked"], 212 + "properties": { 213 + "prefix": { 214 + "type": "string", 215 + "description": "First 12 characters of the key (e.g., 'ckapi_ab12cd') for identification in logs and UI" 216 + }, 217 + "createdAt": { 218 + "type": "string", 219 + "format": "datetime", 220 + "description": "When the key was created" 221 + }, 222 + "lastUsedAt": { 223 + "type": "string", 224 + "format": "datetime", 225 + "description": "When the key was last used for authentication" 226 + }, 227 + "isRevoked": { 228 + "type": "boolean", 229 + "description": "Whether the key has been revoked" 230 + }, 231 + "revokedAt": { 232 + "type": "string", 233 + "format": "datetime", 234 + "description": "When the key was revoked" 235 + } 236 + } 207 237 } 208 238 } 209 239 }
+47
internal/atproto/lexicon/social/coves/aggregator/getApiKey.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.aggregator.getApiKey", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get information about the authenticated aggregator's API key. Note: The actual key value is NEVER returned - only metadata about the key.", 8 + "parameters": { 9 + "type": "params", 10 + "description": "No parameters required. Returns key info for the authenticated aggregator.", 11 + "properties": {} 12 + }, 13 + "output": { 14 + "encoding": "application/json", 15 + "schema": { 16 + "type": "object", 17 + "required": ["hasKey"], 18 + "properties": { 19 + "hasKey": { 20 + "type": "boolean", 21 + "description": "Whether the aggregator has an API key (active or revoked)" 22 + }, 23 + "keyInfo": { 24 + "type": "ref", 25 + "ref": "social.coves.aggregator.defs#apiKeyView", 26 + "description": "API key metadata. Only present if hasKey is true." 27 + } 28 + } 29 + } 30 + }, 31 + "errors": [ 32 + { 33 + "name": "AuthenticationRequired", 34 + "description": "Authentication is required to get API key info" 35 + }, 36 + { 37 + "name": "AggregatorRequired", 38 + "description": "Only registered aggregators can get API key info" 39 + }, 40 + { 41 + "name": "AggregatorNotFound", 42 + "description": "Aggregator not found" 43 + } 44 + ] 45 + } 46 + } 47 + }
+58
internal/atproto/lexicon/social/coves/aggregator/revokeApiKey.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.aggregator.revokeApiKey", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Revoke the authenticated aggregator's API key. After revocation, the aggregator must complete OAuth flow again to create a new API key. This action cannot be undone.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "description": "No input required. Revokes the key for the authenticated aggregator.", 13 + "properties": {} 14 + } 15 + }, 16 + "output": { 17 + "encoding": "application/json", 18 + "schema": { 19 + "type": "object", 20 + "required": ["revokedAt"], 21 + "properties": { 22 + "revokedAt": { 23 + "type": "string", 24 + "format": "datetime", 25 + "description": "ISO8601 timestamp when the key was revoked" 26 + } 27 + } 28 + } 29 + }, 30 + "errors": [ 31 + { 32 + "name": "AuthenticationRequired", 33 + "description": "Authentication is required to revoke an API key" 34 + }, 35 + { 36 + "name": "AggregatorRequired", 37 + "description": "Only registered aggregators can revoke API keys" 38 + }, 39 + { 40 + "name": "AggregatorNotFound", 41 + "description": "Aggregator not found" 42 + }, 43 + { 44 + "name": "ApiKeyNotFound", 45 + "description": "No API key exists to revoke" 46 + }, 47 + { 48 + "name": "ApiKeyAlreadyRevoked", 49 + "description": "API key has already been revoked" 50 + }, 51 + { 52 + "name": "RevocationFailed", 53 + "description": "Failed to revoke the API key" 54 + } 55 + ] 56 + } 57 + } 58 + }
+77
internal/db/migrations/024_add_aggregator_api_keys.sql
··· 1 + -- +goose Up 2 + -- Add API key authentication and OAuth credential storage for aggregators 3 + -- This enables aggregators to authenticate using API keys backed by OAuth sessions 4 + 5 + -- ============================================================================ 6 + -- Add API key columns to aggregators table 7 + -- ============================================================================ 8 + ALTER TABLE aggregators 9 + -- API key identification (prefix for log correlation, hash for auth) 10 + ADD COLUMN api_key_prefix VARCHAR(12), 11 + ADD COLUMN api_key_hash VARCHAR(64) UNIQUE, 12 + 13 + -- OAuth credentials (encrypted at application layer before storage) 14 + -- SECURITY: These columns contain sensitive OAuth tokens 15 + ADD COLUMN oauth_access_token TEXT, 16 + ADD COLUMN oauth_refresh_token TEXT, 17 + ADD COLUMN oauth_token_expires_at TIMESTAMPTZ, 18 + 19 + -- OAuth session metadata for token refresh 20 + ADD COLUMN oauth_pds_url TEXT, 21 + ADD COLUMN oauth_auth_server_iss TEXT, 22 + ADD COLUMN oauth_auth_server_token_endpoint TEXT, 23 + 24 + -- DPoP keys and nonces for token refresh (multibase encoded) 25 + -- SECURITY: Contains private key material 26 + ADD COLUMN oauth_dpop_private_key_multibase TEXT, 27 + ADD COLUMN oauth_dpop_authserver_nonce TEXT, 28 + ADD COLUMN oauth_dpop_pds_nonce TEXT, 29 + 30 + -- API key lifecycle timestamps 31 + ADD COLUMN api_key_created_at TIMESTAMPTZ, 32 + ADD COLUMN api_key_revoked_at TIMESTAMPTZ, 33 + ADD COLUMN api_key_last_used_at TIMESTAMPTZ; 34 + 35 + -- Index for API key lookup during authentication 36 + -- Partial index excludes NULL values since not all aggregators have API keys 37 + CREATE INDEX idx_aggregators_api_key_hash 38 + ON aggregators(api_key_hash) 39 + WHERE api_key_hash IS NOT NULL; 40 + 41 + -- ============================================================================ 42 + -- Security comments on sensitive columns 43 + -- ============================================================================ 44 + COMMENT ON COLUMN aggregators.api_key_prefix IS 'First 12 characters of API key for identification in logs (not secret)'; 45 + COMMENT ON COLUMN aggregators.api_key_hash IS 'SHA-256 hash of full API key for authentication lookup'; 46 + COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: Encrypted OAuth access token for PDS operations'; 47 + COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: Encrypted OAuth refresh token for session renewal'; 48 + COMMENT ON COLUMN aggregators.oauth_token_expires_at IS 'When the OAuth access token expires (triggers refresh)'; 49 + COMMENT ON COLUMN aggregators.oauth_pds_url IS 'PDS URL for this aggregators OAuth session'; 50 + COMMENT ON COLUMN aggregators.oauth_auth_server_iss IS 'OAuth authorization server issuer URL'; 51 + COMMENT ON COLUMN aggregators.oauth_auth_server_token_endpoint IS 'OAuth token refresh endpoint URL'; 52 + COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh'; 53 + COMMENT ON COLUMN aggregators.oauth_dpop_authserver_nonce IS 'Latest DPoP nonce from authorization server'; 54 + COMMENT ON COLUMN aggregators.oauth_dpop_pds_nonce IS 'Latest DPoP nonce from PDS'; 55 + COMMENT ON COLUMN aggregators.api_key_created_at IS 'When the API key was generated'; 56 + COMMENT ON COLUMN aggregators.api_key_revoked_at IS 'When the API key was revoked (NULL = active)'; 57 + COMMENT ON COLUMN aggregators.api_key_last_used_at IS 'Last successful authentication using this API key'; 58 + 59 + -- +goose Down 60 + -- Remove API key columns from aggregators table 61 + DROP INDEX IF EXISTS idx_aggregators_api_key_hash; 62 + 63 + ALTER TABLE aggregators 64 + DROP COLUMN IF EXISTS api_key_prefix, 65 + DROP COLUMN IF EXISTS api_key_hash, 66 + DROP COLUMN IF EXISTS oauth_access_token, 67 + DROP COLUMN IF EXISTS oauth_refresh_token, 68 + DROP COLUMN IF EXISTS oauth_token_expires_at, 69 + DROP COLUMN IF EXISTS oauth_pds_url, 70 + DROP COLUMN IF EXISTS oauth_auth_server_iss, 71 + DROP COLUMN IF EXISTS oauth_auth_server_token_endpoint, 72 + DROP COLUMN IF EXISTS oauth_dpop_private_key_multibase, 73 + DROP COLUMN IF EXISTS oauth_dpop_authserver_nonce, 74 + DROP COLUMN IF EXISTS oauth_dpop_pds_nonce, 75 + DROP COLUMN IF EXISTS api_key_created_at, 76 + DROP COLUMN IF EXISTS api_key_revoked_at, 77 + DROP COLUMN IF EXISTS api_key_last_used_at;
+92
internal/db/migrations/025_encrypt_aggregator_oauth_tokens.sql
··· 1 + -- +goose Up 2 + -- Encrypt aggregator OAuth tokens at rest using pgp_sym_encrypt 3 + -- This addresses the security issue where OAuth tokens were stored in plaintext 4 + -- despite migration 024 claiming "encrypted at application layer before storage" 5 + 6 + -- +goose StatementBegin 7 + 8 + -- Step 1: Add new encrypted columns for OAuth tokens and DPoP private key 9 + ALTER TABLE aggregators 10 + ADD COLUMN oauth_access_token_encrypted BYTEA, 11 + ADD COLUMN oauth_refresh_token_encrypted BYTEA, 12 + ADD COLUMN oauth_dpop_private_key_encrypted BYTEA; 13 + 14 + -- Step 2: Migrate existing plaintext data to encrypted columns 15 + -- Uses the same encryption key table as community credentials (migration 006) 16 + UPDATE aggregators 17 + SET 18 + oauth_access_token_encrypted = CASE 19 + WHEN oauth_access_token IS NOT NULL AND oauth_access_token != '' 20 + THEN pgp_sym_encrypt(oauth_access_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 21 + ELSE NULL 22 + END, 23 + oauth_refresh_token_encrypted = CASE 24 + WHEN oauth_refresh_token IS NOT NULL AND oauth_refresh_token != '' 25 + THEN pgp_sym_encrypt(oauth_refresh_token, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 26 + ELSE NULL 27 + END, 28 + oauth_dpop_private_key_encrypted = CASE 29 + WHEN oauth_dpop_private_key_multibase IS NOT NULL AND oauth_dpop_private_key_multibase != '' 30 + THEN pgp_sym_encrypt(oauth_dpop_private_key_multibase, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 31 + ELSE NULL 32 + END 33 + WHERE oauth_access_token IS NOT NULL 34 + OR oauth_refresh_token IS NOT NULL 35 + OR oauth_dpop_private_key_multibase IS NOT NULL; 36 + 37 + -- Step 3: Drop the old plaintext columns 38 + ALTER TABLE aggregators 39 + DROP COLUMN oauth_access_token, 40 + DROP COLUMN oauth_refresh_token, 41 + DROP COLUMN oauth_dpop_private_key_multibase; 42 + 43 + -- Step 4: Add security comments 44 + COMMENT ON COLUMN aggregators.oauth_access_token_encrypted IS 'SENSITIVE: Encrypted OAuth access token (pgp_sym_encrypt) for PDS operations'; 45 + COMMENT ON COLUMN aggregators.oauth_refresh_token_encrypted IS 'SENSITIVE: Encrypted OAuth refresh token (pgp_sym_encrypt) for session renewal'; 46 + COMMENT ON COLUMN aggregators.oauth_dpop_private_key_encrypted IS 'SENSITIVE: Encrypted DPoP private key (pgp_sym_encrypt) for token refresh'; 47 + 48 + -- +goose StatementEnd 49 + 50 + -- +goose Down 51 + -- +goose StatementBegin 52 + 53 + -- Restore plaintext columns 54 + ALTER TABLE aggregators 55 + ADD COLUMN oauth_access_token TEXT, 56 + ADD COLUMN oauth_refresh_token TEXT, 57 + ADD COLUMN oauth_dpop_private_key_multibase TEXT; 58 + 59 + -- Decrypt data back to plaintext (for rollback) 60 + UPDATE aggregators 61 + SET 62 + oauth_access_token = CASE 63 + WHEN oauth_access_token_encrypted IS NOT NULL 64 + THEN pgp_sym_decrypt(oauth_access_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 65 + ELSE NULL 66 + END, 67 + oauth_refresh_token = CASE 68 + WHEN oauth_refresh_token_encrypted IS NOT NULL 69 + THEN pgp_sym_decrypt(oauth_refresh_token_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 70 + ELSE NULL 71 + END, 72 + oauth_dpop_private_key_multibase = CASE 73 + WHEN oauth_dpop_private_key_encrypted IS NOT NULL 74 + THEN pgp_sym_decrypt(oauth_dpop_private_key_encrypted, (SELECT encode(key_data, 'hex') FROM encryption_keys WHERE id = 1)) 75 + ELSE NULL 76 + END 77 + WHERE oauth_access_token_encrypted IS NOT NULL 78 + OR oauth_refresh_token_encrypted IS NOT NULL 79 + OR oauth_dpop_private_key_encrypted IS NOT NULL; 80 + 81 + -- Drop encrypted columns 82 + ALTER TABLE aggregators 83 + DROP COLUMN oauth_access_token_encrypted, 84 + DROP COLUMN oauth_refresh_token_encrypted, 85 + DROP COLUMN oauth_dpop_private_key_encrypted; 86 + 87 + -- Restore comments 88 + COMMENT ON COLUMN aggregators.oauth_access_token IS 'SENSITIVE: OAuth access token for PDS operations'; 89 + COMMENT ON COLUMN aggregators.oauth_refresh_token IS 'SENSITIVE: OAuth refresh token for session renewal'; 90 + COMMENT ON COLUMN aggregators.oauth_dpop_private_key_multibase IS 'SENSITIVE: DPoP private key in multibase format for token refresh'; 91 + 92 + -- +goose StatementEnd
+28 -6
internal/api/handlers/aggregator/errors.go
··· 3 3 import ( 4 4 "Coves/internal/core/aggregators" 5 5 "Coves/internal/core/communities" 6 + "bytes" 6 7 "encoding/json" 7 8 "log" 8 9 "net/http" ··· 14 15 Message string `json:"message"` 15 16 } 16 17 17 - // writeError writes a JSON error response 18 - func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 18 + // writeJSONResponse buffers the JSON encoding before sending headers. 19 + // This ensures that encoding failures don't result in partial responses 20 + // with already-sent headers. Returns true if the response was written 21 + // successfully, false otherwise. 22 + func writeJSONResponse(w http.ResponseWriter, statusCode int, data interface{}) bool { 23 + // Buffer the JSON first to detect encoding errors before sending headers 24 + var buf bytes.Buffer 25 + if err := json.NewEncoder(&buf).Encode(data); err != nil { 26 + log.Printf("ERROR: Failed to encode JSON response: %v", err) 27 + // Send a proper error response since we haven't sent headers yet 28 + w.Header().Set("Content-Type", "application/json") 29 + w.WriteHeader(http.StatusInternalServerError) 30 + _, _ = w.Write([]byte(`{"error":"InternalServerError","message":"Failed to encode response"}`)) 31 + return false 32 + } 33 + 19 34 w.Header().Set("Content-Type", "application/json") 20 35 w.WriteHeader(statusCode) 21 - if err := json.NewEncoder(w).Encode(ErrorResponse{ 36 + if _, err := w.Write(buf.Bytes()); err != nil { 37 + log.Printf("ERROR: Failed to write response body: %v", err) 38 + return false 39 + } 40 + return true 41 + } 42 + 43 + // writeError writes a JSON error response with proper buffering 44 + func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 45 + writeJSONResponse(w, statusCode, ErrorResponse{ 22 46 Error: errorType, 23 47 Message: message, 24 - }); err != nil { 25 - log.Printf("ERROR: Failed to encode error response: %v", err) 26 - } 48 + }) 27 49 } 28 50 29 51 // handleServiceError maps service errors to HTTP responses
+42
internal/api/handlers/aggregator/metrics.go
··· 1 + package aggregator 2 + 3 + import ( 4 + "net/http" 5 + 6 + "Coves/internal/core/aggregators" 7 + ) 8 + 9 + // MetricsHandler provides API key service metrics for monitoring 10 + type MetricsHandler struct { 11 + apiKeyService aggregators.APIKeyServiceInterface 12 + } 13 + 14 + // NewMetricsHandler creates a new metrics handler 15 + func NewMetricsHandler(apiKeyService aggregators.APIKeyServiceInterface) *MetricsHandler { 16 + return &MetricsHandler{ 17 + apiKeyService: apiKeyService, 18 + } 19 + } 20 + 21 + // MetricsResponse contains API key service operational metrics 22 + type MetricsResponse struct { 23 + FailedLastUsedUpdates int64 `json:"failedLastUsedUpdates"` 24 + FailedNonceUpdates int64 `json:"failedNonceUpdates"` 25 + } 26 + 27 + // HandleMetrics handles GET /xrpc/social.coves.aggregator.getMetrics 28 + // Returns operational metrics for the API key service. 29 + // This endpoint is intended for internal monitoring and health checks. 30 + func (h *MetricsHandler) HandleMetrics(w http.ResponseWriter, r *http.Request) { 31 + if r.Method != http.MethodGet { 32 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 33 + return 34 + } 35 + 36 + response := MetricsResponse{ 37 + FailedLastUsedUpdates: h.apiKeyService.GetFailedLastUsedUpdates(), 38 + FailedNonceUpdates: h.apiKeyService.GetFailedNonceUpdates(), 39 + } 40 + 41 + writeJSONResponse(w, http.StatusOK, response) 42 + }
+7 -4
.env.dev
··· 38 38 PDS_ADMIN_PASSWORD=admin 39 39 40 40 # Handle domains (users will get handles like alice.local.coves.dev) 41 - # Communities will use .community.coves.social (singular per atProto conventions) 42 - PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.community.coves.social 41 + # Communities will use c-{name}.coves.social (3-level format with c- prefix) 42 + PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.coves.social 43 43 44 44 # PLC Rotation Key (k256 private key in hex format - for local dev only) 45 45 # This is a randomly generated key for testing - DO NOT use in production ··· 133 133 PDS_INSTANCE_HANDLE=testuser123.local.coves.dev 134 134 PDS_INSTANCE_PASSWORD=test-password-123 135 135 136 - # Kagi News Aggregator DID (for trusted thumbnail URLs) 137 - KAGI_AGGREGATOR_DID=did:plc:yyf34padpfjknejyutxtionr 136 + # Trusted Aggregator DIDs (bypasses community authorization check) 137 + # Comma-separated list of DIDs 138 + # - did:plc:yyf34padpfjknejyutxtionr = kagi-news.coves.social (production) 139 + # - did:plc:igjbg5cex7poojsniebvmafb = test-aggregator.local.coves.dev (dev) 140 + TRUSTED_AGGREGATOR_DIDS=did:plc:yyf34padpfjknejyutxtionr,did:plc:igjbg5cex7poojsniebvmafb 138 141 139 142 # ============================================================================= 140 143 # Development Settings
+1 -1
.env.dev.example
··· 46 46 PDS_DID_PLC_URL=http://plc-directory:3000 47 47 PDS_JWT_SECRET=local-dev-jwt-secret-change-in-production 48 48 PDS_ADMIN_PASSWORD=admin 49 - PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.community.coves.social 49 + PDS_SERVICE_HANDLE_DOMAINS=.local.coves.dev,.coves.social 50 50 PDS_PLC_ROTATION_KEY=<generate-a-random-hex-key> 51 51 52 52 # =============================================================================
+2 -3
aggregators/kagi-news/.env.example
··· 1 - # Aggregator Identity (pre-created account credentials) 2 - AGGREGATOR_HANDLE=kagi-news.local.coves.dev 3 - AGGREGATOR_PASSWORD=your-secure-password-here 1 + # Coves API Key (get from https://coves.social after OAuth login) 2 + COVES_API_KEY=ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 4 3 5 4 # Optional: Override Coves API URL (defaults to config.yaml) 6 5 # COVES_API_URL=http://localhost:3001
+2
aggregators/kagi-news/config.example.yaml
··· 2 2 3 3 # Coves API endpoint 4 4 coves_api_url: "https://coves.social" 5 + # API key is loaded from COVES_API_KEY environment variable 6 + # Get your API key from https://coves.social after OAuth login 5 7 6 8 # Feed-to-community mappings 7 9 # Handle format: c-{name}.{instance} (e.g., c-worldnews.coves.social)
-1
aggregators/kagi-news/requirements.txt
··· 2 2 feedparser==6.0.11 3 3 beautifulsoup4==4.12.3 4 4 requests==2.31.0 5 - atproto==0.0.55 6 5 pyyaml==6.0.1 7 6 8 7 # Testing
+133 -55
aggregators/kagi-news/src/coves_client.py
··· 1 1 """ 2 2 Coves API Client for posting to communities. 3 3 4 - Handles authentication and posting via XRPC. 4 + Handles API key authentication and posting via XRPC. 5 5 """ 6 6 import logging 7 7 import requests 8 8 from typing import Dict, List, Optional 9 - from atproto import Client 10 9 11 10 logger = logging.getLogger(__name__) 12 11 13 12 13 + class CovesAPIError(Exception): 14 + """Base exception for Coves API errors.""" 15 + 16 + def __init__(self, message: str, status_code: int = None, response_body: str = None): 17 + super().__init__(message) 18 + self.status_code = status_code 19 + self.response_body = response_body 20 + 21 + 22 + class CovesAuthenticationError(CovesAPIError): 23 + """Raised when authentication fails (401 Unauthorized).""" 24 + pass 25 + 26 + 27 + class CovesNotFoundError(CovesAPIError): 28 + """Raised when a resource is not found (404 Not Found).""" 29 + pass 30 + 31 + 32 + class CovesRateLimitError(CovesAPIError): 33 + """Raised when rate limit is exceeded (429 Too Many Requests).""" 34 + pass 35 + 36 + 37 + class CovesForbiddenError(CovesAPIError): 38 + """Raised when access is forbidden (403 Forbidden).""" 39 + pass 40 + 41 + 14 42 class CovesClient: 15 43 """ 16 44 Client for posting to Coves communities via XRPC. 17 45 18 46 Handles: 19 - - Authentication with aggregator credentials 47 + - API key authentication 20 48 - Creating posts in communities (social.coves.community.post.create) 21 49 - External embed formatting 22 50 """ 23 51 24 - def __init__(self, api_url: str, handle: str, password: str, pds_url: Optional[str] = None): 25 - """ 26 - Initialize Coves client. 27 - 28 - Args: 29 - api_url: Coves AppView URL for posting (e.g., "http://localhost:8081") 30 - handle: Aggregator handle (e.g., "kagi-news.coves.social") 31 - password: Aggregator password/app password 32 - pds_url: Optional PDS URL for authentication (defaults to api_url) 33 - """ 34 - self.api_url = api_url 35 - self.pds_url = pds_url or api_url # Auth through PDS, post through AppView 36 - self.handle = handle 37 - self.password = password 38 - self.client = Client(base_url=self.pds_url) # Use PDS for auth 39 - self._authenticated = False 52 + # API key format constants (must match Go constants in apikey_service.go) 53 + API_KEY_PREFIX = "ckapi_" 54 + API_KEY_TOTAL_LENGTH = 70 # 6 (prefix) + 64 (32 bytes hex-encoded) 40 55 41 - def authenticate(self): 56 + def __init__(self, api_url: str, api_key: str): 42 57 """ 43 - Authenticate with Coves API. 58 + Initialize Coves client with API key authentication. 44 59 45 - Uses com.atproto.server.createSession directly to avoid 46 - Bluesky-specific endpoints that don't exist on Coves PDS. 60 + Args: 61 + api_url: Coves API URL for posting (e.g., "https://coves.social") 62 + api_key: Coves API key (e.g., "ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") 47 63 48 64 Raises: 49 - Exception: If authentication fails 65 + ValueError: If api_key format is invalid 50 66 """ 51 - try: 52 - logger.info(f"Authenticating as {self.handle}") 53 - 54 - # Use createSession directly (avoid app.bsky.actor.getProfile) 55 - session = self.client.com.atproto.server.create_session( 56 - {"identifier": self.handle, "password": self.password} 67 + # Validate API key format for early failure with clear error 68 + if not api_key: 69 + raise ValueError("API key cannot be empty") 70 + if not api_key.startswith(self.API_KEY_PREFIX): 71 + raise ValueError(f"API key must start with '{self.API_KEY_PREFIX}'") 72 + if len(api_key) != self.API_KEY_TOTAL_LENGTH: 73 + raise ValueError( 74 + f"API key must be {self.API_KEY_TOTAL_LENGTH} characters " 75 + f"(got {len(api_key)})" 57 76 ) 58 77 59 - # Manually set session (skip profile fetch) 60 - self.client._session = session 61 - self._authenticated = True 62 - self.did = session.did 78 + self.api_url = api_url.rstrip('/') 79 + self.api_key = api_key 80 + self.session = requests.Session() 81 + self.session.headers['Authorization'] = f'Bearer {api_key}' 82 + self.session.headers['Content-Type'] = 'application/json' 63 83 64 - logger.info(f"Authentication successful (DID: {self.did})") 65 - except Exception as e: 66 - logger.error(f"Authentication failed: {e}") 67 - raise 84 + def authenticate(self): 85 + """ 86 + No-op for API key authentication. 87 + 88 + API key is set in the session headers during initialization. 89 + This method is kept for backward compatibility with existing code 90 + that calls authenticate() before making requests. 91 + """ 92 + logger.info("Using API key authentication (no session creation needed)") 68 93 69 94 def create_post( 70 95 self, ··· 90 115 AT Proto URI of created post (e.g., "at://did:plc:.../social.coves.post/...") 91 116 92 117 Raises: 93 - Exception: If post creation fails 118 + requests.HTTPError: If post creation fails 94 119 """ 95 - if not self._authenticated: 96 - self.authenticate() 97 - 98 120 try: 99 121 # Prepare post data for social.coves.community.post.create endpoint 100 122 post_data = { ··· 119 141 # This provides validation, authorization, and business logic 120 142 logger.info(f"Creating post in community: {community_handle}") 121 143 122 - # Make direct HTTP request to XRPC endpoint 144 + # Make HTTP request to XRPC endpoint using session with API key 123 145 url = f"{self.api_url}/xrpc/social.coves.community.post.create" 124 - headers = { 125 - "Authorization": f"Bearer {self.client._session.access_jwt}", 126 - "Content-Type": "application/json" 127 - } 128 - 129 - response = requests.post(url, json=post_data, headers=headers, timeout=30) 146 + response = self.session.post(url, json=post_data, timeout=30) 130 147 131 - # Log detailed error if request fails 148 + # Handle specific error cases 132 149 if not response.ok: 133 150 error_body = response.text 134 151 logger.error(f"Post creation failed ({response.status_code}): {error_body}") 135 - response.raise_for_status() 152 + self._raise_for_status(response) 153 + 154 + try: 155 + result = response.json() 156 + post_uri = result["uri"] 157 + except (ValueError, KeyError) as e: 158 + # ValueError for invalid JSON, KeyError for missing 'uri' field 159 + logger.error(f"Failed to parse post creation response: {e}") 160 + raise CovesAPIError( 161 + f"Invalid response from server: {e}", 162 + status_code=response.status_code, 163 + response_body=response.text 164 + ) 136 165 137 - result = response.json() 138 - post_uri = result["uri"] 139 166 logger.info(f"Post created: {post_uri}") 140 167 return post_uri 141 168 142 - except Exception as e: 143 - logger.error(f"Failed to create post: {e}") 169 + except requests.RequestException as e: 170 + # Network errors, timeouts, etc. 171 + logger.error(f"Network error creating post: {e}") 172 + raise 173 + except CovesAPIError: 174 + # Re-raise our custom exceptions as-is 144 175 raise 145 176 146 177 def create_external_embed( ··· 176 207 "external": external 177 208 } 178 209 210 + def _raise_for_status(self, response: requests.Response) -> None: 211 + """ 212 + Raise specific exceptions based on HTTP status code. 213 + 214 + Args: 215 + response: The HTTP response object 216 + 217 + Raises: 218 + CovesAuthenticationError: For 401 Unauthorized 219 + CovesNotFoundError: For 404 Not Found 220 + CovesRateLimitError: For 429 Too Many Requests 221 + CovesAPIError: For other 4xx/5xx errors 222 + """ 223 + status_code = response.status_code 224 + error_body = response.text 225 + 226 + if status_code == 401: 227 + raise CovesAuthenticationError( 228 + f"Authentication failed: {error_body}", 229 + status_code=status_code, 230 + response_body=error_body 231 + ) 232 + elif status_code == 403: 233 + raise CovesForbiddenError( 234 + f"Access forbidden: {error_body}", 235 + status_code=status_code, 236 + response_body=error_body 237 + ) 238 + elif status_code == 404: 239 + raise CovesNotFoundError( 240 + f"Resource not found: {error_body}", 241 + status_code=status_code, 242 + response_body=error_body 243 + ) 244 + elif status_code == 429: 245 + raise CovesRateLimitError( 246 + f"Rate limit exceeded: {error_body}", 247 + status_code=status_code, 248 + response_body=error_body 249 + ) 250 + else: 251 + raise CovesAPIError( 252 + f"API request failed ({status_code}): {error_body}", 253 + status_code=status_code, 254 + response_body=error_body 255 + ) 256 + 179 257 def _get_timestamp(self) -> str: 180 258 """ 181 259 Get current timestamp in ISO 8601 format.
+5 -9
aggregators/kagi-news/src/main.py
··· 71 71 if coves_client: 72 72 self.coves_client = coves_client 73 73 else: 74 - # Get credentials from environment 75 - aggregator_handle = os.getenv('AGGREGATOR_HANDLE') 76 - aggregator_password = os.getenv('AGGREGATOR_PASSWORD') 77 - pds_url = os.getenv('PDS_URL') # Optional: separate PDS for auth 74 + # Get API key from environment 75 + api_key = os.getenv('COVES_API_KEY') 78 76 79 - if not aggregator_handle or not aggregator_password: 77 + if not api_key: 80 78 raise ValueError( 81 - "Missing AGGREGATOR_HANDLE or AGGREGATOR_PASSWORD environment variables" 79 + "COVES_API_KEY environment variable required" 82 80 ) 83 81 84 82 self.coves_client = CovesClient( 85 83 api_url=self.config.coves_api_url, 86 - handle=aggregator_handle, 87 - password=aggregator_password, 88 - pds_url=pds_url # Auth through PDS if specified 84 + api_key=api_key 89 85 ) 90 86 91 87 def run(self):
+127 -3
aggregators/kagi-news/tests/test_coves_client.py
··· 4 4 Tests the client's local functionality without requiring live infrastructure. 5 5 """ 6 6 import pytest 7 - from src.coves_client import CovesClient 7 + from unittest.mock import Mock 8 + from src.coves_client import ( 9 + CovesClient, 10 + CovesAPIError, 11 + CovesAuthenticationError, 12 + CovesForbiddenError, 13 + CovesNotFoundError, 14 + CovesRateLimitError, 15 + ) 16 + 17 + 18 + # Valid test API key (70 chars total: 6 prefix + 64 hex chars) 19 + VALID_TEST_API_KEY = "ckapi_" + "a" * 64 20 + 21 + 22 + class TestAPIKeyValidation: 23 + """Tests for API key format validation in constructor.""" 24 + 25 + def test_rejects_empty_api_key(self): 26 + """Empty API key should raise ValueError.""" 27 + with pytest.raises(ValueError, match="cannot be empty"): 28 + CovesClient(api_url="http://localhost", api_key="") 29 + 30 + def test_rejects_wrong_prefix(self): 31 + """API key with wrong prefix should raise ValueError.""" 32 + wrong_prefix_key = "wrong_" + "a" * 64 33 + with pytest.raises(ValueError, match="must start with 'ckapi_'"): 34 + CovesClient(api_url="http://localhost", api_key=wrong_prefix_key) 35 + 36 + def test_rejects_short_api_key(self): 37 + """API key that is too short should raise ValueError.""" 38 + short_key = "ckapi_tooshort" 39 + with pytest.raises(ValueError, match="must be 70 characters"): 40 + CovesClient(api_url="http://localhost", api_key=short_key) 41 + 42 + def test_rejects_long_api_key(self): 43 + """API key that is too long should raise ValueError.""" 44 + long_key = "ckapi_" + "a" * 100 45 + with pytest.raises(ValueError, match="must be 70 characters"): 46 + CovesClient(api_url="http://localhost", api_key=long_key) 47 + 48 + def test_accepts_valid_api_key(self): 49 + """Valid API key format should be accepted.""" 50 + client = CovesClient(api_url="http://localhost", api_key=VALID_TEST_API_KEY) 51 + assert client.api_key == VALID_TEST_API_KEY 52 + 53 + 54 + class TestRaiseForStatus: 55 + """Tests for _raise_for_status method.""" 56 + 57 + @pytest.fixture 58 + def client(self): 59 + """Create a CovesClient instance for testing.""" 60 + return CovesClient(api_url="http://localhost", api_key=VALID_TEST_API_KEY) 61 + 62 + def test_raises_authentication_error_for_401(self, client): 63 + """401 response should raise CovesAuthenticationError.""" 64 + mock_response = Mock() 65 + mock_response.status_code = 401 66 + mock_response.text = "Invalid API key" 67 + 68 + with pytest.raises(CovesAuthenticationError) as exc_info: 69 + client._raise_for_status(mock_response) 70 + 71 + assert exc_info.value.status_code == 401 72 + assert "Authentication failed" in str(exc_info.value) 73 + 74 + def test_raises_forbidden_error_for_403(self, client): 75 + """403 response should raise CovesForbiddenError.""" 76 + mock_response = Mock() 77 + mock_response.status_code = 403 78 + mock_response.text = "Not authorized for this community" 79 + 80 + with pytest.raises(CovesForbiddenError) as exc_info: 81 + client._raise_for_status(mock_response) 82 + 83 + assert exc_info.value.status_code == 403 84 + assert "Access forbidden" in str(exc_info.value) 85 + 86 + def test_raises_not_found_error_for_404(self, client): 87 + """404 response should raise CovesNotFoundError.""" 88 + mock_response = Mock() 89 + mock_response.status_code = 404 90 + mock_response.text = "Community not found" 91 + 92 + with pytest.raises(CovesNotFoundError) as exc_info: 93 + client._raise_for_status(mock_response) 94 + 95 + assert exc_info.value.status_code == 404 96 + assert "Resource not found" in str(exc_info.value) 97 + 98 + def test_raises_rate_limit_error_for_429(self, client): 99 + """429 response should raise CovesRateLimitError.""" 100 + mock_response = Mock() 101 + mock_response.status_code = 429 102 + mock_response.text = "Rate limit exceeded" 103 + 104 + with pytest.raises(CovesRateLimitError) as exc_info: 105 + client._raise_for_status(mock_response) 106 + 107 + assert exc_info.value.status_code == 429 108 + assert "Rate limit exceeded" in str(exc_info.value) 109 + 110 + def test_raises_generic_api_error_for_500(self, client): 111 + """500 response should raise generic CovesAPIError.""" 112 + mock_response = Mock() 113 + mock_response.status_code = 500 114 + mock_response.text = "Internal server error" 115 + 116 + with pytest.raises(CovesAPIError) as exc_info: 117 + client._raise_for_status(mock_response) 118 + 119 + assert exc_info.value.status_code == 500 120 + assert not isinstance(exc_info.value, CovesAuthenticationError) 121 + assert not isinstance(exc_info.value, CovesNotFoundError) 122 + 123 + def test_exception_includes_response_body(self, client): 124 + """Exception should include the response body.""" 125 + mock_response = Mock() 126 + mock_response.status_code = 400 127 + mock_response.text = '{"error": "Bad request details"}' 128 + 129 + with pytest.raises(CovesAPIError) as exc_info: 130 + client._raise_for_status(mock_response) 131 + 132 + assert exc_info.value.response_body == '{"error": "Bad request details"}' 8 133 9 134 10 135 class TestCreateExternalEmbed: ··· 15 140 """Create a CovesClient instance for testing.""" 16 141 return CovesClient( 17 142 api_url="http://localhost:8081", 18 - handle="test.handle", 19 - password="test_password" 143 + api_key=VALID_TEST_API_KEY 20 144 ) 21 145 22 146 def test_creates_embed_without_sources(self, client):
+24 -15
aggregators/kagi-news/README.md
··· 47 47 48 48 Before running the aggregator, you must register it with a Coves instance. This creates a DID for your aggregator and registers it with Coves. 49 49 50 + ### Handle Options 51 + 52 + You have two choices: 53 + 54 + 1. **PDS-assigned handle** (simpler): Use `my-aggregator.bsky.social`. No domain verification needed. 55 + 2. **Custom domain** (branded): Use `news.example.com`. Requires hosting a `.well-known/atproto-did` file. 56 + 50 57 ### Quick Setup (Automated) 51 58 52 59 The automated setup script handles the entire registration process: ··· 59 66 60 67 This will: 61 68 1. **Create a PDS account** for your aggregator (generates a DID) 62 - 2. **Generate `.well-known/atproto-did`** file for domain verification 63 - 3. **Pause for manual upload** - you'll upload the file to your web server 64 - 4. **Register with Coves** instance via XRPC 65 - 5. **Create service declaration** record (indexed by Jetstream) 69 + 2. **(Optional)** Generate `.well-known/atproto-did` file for custom domain handle 70 + 3. **(Optional)** Pause for manual upload if using custom domain 71 + 4. **Create service declaration** record (indexed by Jetstream) 72 + 5. **Generate an API key** for authentication (requires browser OAuth) 66 73 67 - **Manual step required:** During the process, you'll need to upload the `.well-known/atproto-did` file to your domain so it's accessible at `https://yourdomain.com/.well-known/atproto-did`. 74 + **Manual steps required:** 75 + - **(If using custom domain)** Upload `.well-known/atproto-did` to your domain 76 + - Complete OAuth login in browser to generate API key 68 77 69 - After completion, you'll have a `kagi-aggregator-config.env` file with: 70 - - Aggregator DID and credentials 71 - - Access/refresh JWTs 72 - - Service declaration URI 78 + After completion, you'll have: 79 + - `kagi-aggregator-config.env` - Full configuration with API key 80 + - `COVES_API_KEY` - Your authentication token for posting 73 81 74 - **Keep this file secure!** It contains your aggregator's credentials. 82 + **Keep the API key secure!** It cannot be retrieved after generation. 75 83 76 84 ### Manual Setup (Step-by-step) 77 85 ··· 81 89 # From the Coves project root 82 90 cd scripts/aggregator-setup 83 91 84 - # Follow the 4-step process 92 + # Follow the 5-step process 85 93 ./1-create-pds-account.sh 86 94 ./2-setup-wellknown.sh 87 95 ./3-register-with-coves.sh 88 96 ./4-create-service-declaration.sh 97 + ./5-create-api-key.sh 89 98 ``` 90 99 91 100 See [scripts/aggregator-setup/README.md](../../scripts/aggregator-setup/README.md) for detailed documentation on each step. ··· 96 105 2. **Domain Verification**: Proves you control your aggregator's domain 97 106 3. **Coves Registration**: Inserts your DID into the Coves instance's `users` table 98 107 4. **Service Declaration**: Creates a record that gets indexed into the `aggregators` table 99 - 5. **Ready for Authorization**: Community moderators can now authorize your aggregator 108 + 5. **API Key Generation**: Creates a secure API key for authentication 109 + 6. **Ready for Authorization**: Community moderators can now authorize your aggregator 100 110 101 111 Once registered and authorized by a community, your aggregator can post content. 102 112 ··· 128 138 ``` 129 139 130 140 4. Edit `config.yaml` to map RSS feeds to communities 131 - 5. Set environment variables in `.env` (aggregator DID and private key) 141 + 5. Set `COVES_API_KEY` in `.env` (from registration step 5) 132 142 133 143 ## Running Tests 134 144 ··· 189 199 190 200 The `docker-compose.yml` file supports these environment variables: 191 201 192 - - **`AGGREGATOR_HANDLE`** (required): Your aggregator's handle 193 - - **`AGGREGATOR_PASSWORD`** (required): Your aggregator's password 202 + - **`COVES_API_KEY`** (required): Your aggregator's API key (format: `ckapi_...`) 194 203 - **`COVES_API_URL`** (optional): Override Coves API endpoint (defaults to `https://api.coves.social`) 195 204 - **`RUN_ON_STARTUP`** (optional): Set to `true` to run immediately on container start (useful for testing) 196 205
+148
scripts/aggregator-setup/5-create-api-key.sh
··· 1 + #!/bin/bash 2 + # 3 + # Step 5: Create API Key for Aggregator 4 + # 5 + # This script guides you through generating an API key for your aggregator. 6 + # API keys are used for authentication instead of PDS JWTs. 7 + # 8 + # Prerequisites: 9 + # - Completed steps 1-4 (PDS account, .well-known, Coves registration, service declaration) 10 + # - Aggregator indexed by Coves (check: curl https://coves.social/xrpc/social.coves.aggregator.get?did=YOUR_DID) 11 + # 12 + # Usage: ./5-create-api-key.sh 13 + # 14 + 15 + set -e 16 + 17 + # Colors for output 18 + RED='\033[0;31m' 19 + GREEN='\033[0;32m' 20 + YELLOW='\033[1;33m' 21 + BLUE='\033[0;34m' 22 + NC='\033[0m' # No Color 23 + 24 + echo -e "${BLUE}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" 25 + echo -e "${BLUE}โ•‘ Coves Aggregator - Step 5: Create API Key โ•‘${NC}" 26 + echo -e "${BLUE}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 27 + echo 28 + 29 + # Load existing configuration 30 + CONFIG_FILE="aggregator-config.env" 31 + if [ -f "$CONFIG_FILE" ]; then 32 + echo -e "${GREEN}โœ“${NC} Loading existing configuration from $CONFIG_FILE" 33 + source "$CONFIG_FILE" 34 + else 35 + echo -e "${YELLOW}โš ${NC} No $CONFIG_FILE found. Please run steps 1-4 first." 36 + echo 37 + read -p "Enter your Coves instance URL [https://coves.social]: " COVES_INSTANCE_URL 38 + COVES_INSTANCE_URL=${COVES_INSTANCE_URL:-https://coves.social} 39 + fi 40 + 41 + echo 42 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 43 + echo -e "${YELLOW} API Key Generation Process${NC}" 44 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 45 + echo 46 + echo "API keys allow your aggregator to authenticate without managing" 47 + echo "OAuth token refresh. The key is shown ONCE and cannot be retrieved later." 48 + echo 49 + echo -e "${BLUE}Steps:${NC}" 50 + echo "1. Complete OAuth login in your browser" 51 + echo "2. Call the createApiKey endpoint" 52 + echo "3. Save the key securely" 53 + echo 54 + 55 + # Check if aggregator is indexed 56 + echo -e "${BLUE}Checking if aggregator is indexed...${NC}" 57 + if [ -n "$AGGREGATOR_DID" ]; then 58 + AGGREGATOR_CHECK=$(curl -s "${COVES_INSTANCE_URL}/xrpc/social.coves.aggregator.get?did=${AGGREGATOR_DID}" 2>/dev/null || echo "error") 59 + if echo "$AGGREGATOR_CHECK" | grep -q "error"; then 60 + echo -e "${YELLOW}โš ${NC} Could not verify aggregator status. Proceeding anyway..." 61 + else 62 + echo -e "${GREEN}โœ“${NC} Aggregator found in Coves instance" 63 + fi 64 + fi 65 + 66 + echo 67 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 68 + echo -e "${YELLOW} Step 5.1: OAuth Login${NC}" 69 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 70 + echo 71 + echo "Open this URL in your browser to authenticate:" 72 + echo 73 + AGGREGATOR_HANDLE=${AGGREGATOR_HANDLE:-"your-aggregator.example.com"} 74 + echo -e " ${BLUE}${COVES_INSTANCE_URL}/oauth/login?handle=${AGGREGATOR_HANDLE}${NC}" 75 + echo 76 + echo "This will:" 77 + echo " 1. Redirect you to your PDS for authentication" 78 + echo " 2. Return you to Coves with an OAuth session" 79 + echo 80 + echo -e "${YELLOW}After authenticating, press Enter to continue...${NC}" 81 + read 82 + 83 + echo 84 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 85 + echo -e "${YELLOW} Step 5.2: Create API Key${NC}" 86 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 87 + echo 88 + echo "In your browser's Developer Console (F12 โ†’ Console), run:" 89 + echo 90 + echo -e "${GREEN}fetch('/xrpc/social.coves.aggregator.createApiKey', {" 91 + echo " method: 'POST'," 92 + echo " credentials: 'include'" 93 + echo "})" 94 + echo ".then(r => r.json())" 95 + echo -e ".then(data => console.log('API Key:', data.key))${NC}" 96 + echo 97 + echo "This will return your API key. It looks like:" 98 + echo " ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 99 + echo 100 + echo -e "${RED}โš  IMPORTANT: Save this key immediately! It cannot be retrieved again.${NC}" 101 + echo 102 + read -p "Enter the API key you received: " API_KEY 103 + 104 + # Validate API key format 105 + if [[ ! $API_KEY =~ ^ckapi_[a-f0-9]{64}$ ]]; then 106 + echo -e "${RED}โœ— Invalid API key format. Expected: ckapi_ followed by 64 hex characters${NC}" 107 + echo " Example: ckapi_dcbdec0a0d1b3c440125547d21fe582bbf1587d2dcd364c56ad285af841cc934" 108 + exit 1 109 + fi 110 + 111 + echo -e "${GREEN}โœ“${NC} API key format valid" 112 + 113 + # Save to config 114 + echo 115 + echo "COVES_API_KEY=\"$API_KEY\"" >> "$CONFIG_FILE" 116 + echo -e "${GREEN}โœ“${NC} API key saved to $CONFIG_FILE" 117 + 118 + echo 119 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 120 + echo -e "${YELLOW} Step 5.3: Update Your .env File${NC}" 121 + echo -e "${YELLOW}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 122 + echo 123 + echo "Update your aggregator's .env file with:" 124 + echo 125 + echo -e "${GREEN}COVES_API_KEY=${API_KEY}${NC}" 126 + echo -e "${GREEN}COVES_API_URL=${COVES_INSTANCE_URL}${NC}" 127 + echo 128 + echo "You can remove the old AGGREGATOR_HANDLE and AGGREGATOR_PASSWORD variables." 129 + echo 130 + 131 + echo 132 + echo -e "${GREEN}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" 133 + echo -e "${GREEN}โ•‘ Setup Complete! โ•‘${NC}" 134 + echo -e "${GREEN}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" 135 + echo 136 + echo "Your aggregator is now configured with API key authentication." 137 + echo 138 + echo "Next steps:" 139 + echo " 1. Update your aggregator's .env file with COVES_API_KEY" 140 + echo " 2. Rebuild your Docker container: docker compose build --no-cache" 141 + echo " 3. Start the aggregator: docker compose up -d" 142 + echo " 4. Check logs: docker compose logs -f" 143 + echo 144 + echo -e "${YELLOW}Security Reminders:${NC}" 145 + echo " - Never commit your API key to version control" 146 + echo " - Store it securely (environment variables or secrets manager)" 147 + echo " - Rotate periodically by generating a new key (revokes the old one)" 148 + echo
+73 -28
scripts/aggregator-setup/README.md
··· 7 7 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: 8 8 9 9 1. Create a PDS account for your aggregator (gets you a DID) 10 - 2. Prove you own a domain via `.well-known/atproto-did` 10 + 2. **(Optional)** Verify a custom domain via `.well-known/atproto-did` 11 11 3. Register with a Coves instance 12 12 4. Create a service declaration record 13 + 5. **Generate an API key** for authentication 13 14 14 15 These scripts automate this process for you. 15 16 17 + ### Handle Options 18 + 19 + You have two choices for your aggregator's handle: 20 + 21 + 1. **PDS-assigned handle** (simpler): Use the handle from your PDS, e.g., `my-aggregator.bsky.social`. No domain verification neededโ€”skip steps 2-3. 22 + 23 + 2. **Custom domain handle** (branded): Use your own domain, e.g., `news.example.com`. Requires hosting a `.well-known/atproto-did` file on your domain. 24 + 16 25 ## Prerequisites 17 26 18 - - **Domain ownership**: You must own a domain where you can host the `.well-known/atproto-did` file 19 - - **Web server**: Ability to serve static files over HTTPS 20 27 - **Tools**: `curl`, `jq` (for JSON processing) 21 28 - **Account**: Email address for creating the PDS account 29 + - **(For custom domain only)**: Domain ownership and ability to serve HTTPS files 22 30 23 31 ## Quick Start 24 32 ··· 33 41 # Step 1: Create PDS account 34 42 ./1-create-pds-account.sh 35 43 36 - # Step 2: Generate .well-known file 37 - ./2-setup-wellknown.sh 38 - 39 - # Step 3: Register with Coves (after uploading .well-known) 40 - ./3-register-with-coves.sh 44 + # Steps 2-3: OPTIONAL - Only if you want a custom domain handle 45 + # ./2-setup-wellknown.sh 46 + # ./3-register-with-coves.sh (after uploading .well-known) 41 47 42 48 # Step 4: Create service declaration 43 49 ./4-create-service-declaration.sh 50 + 51 + # Step 5: Generate API key (requires browser for OAuth) 52 + ./5-create-api-key.sh 44 53 ``` 45 54 55 + **Minimal setup** (PDS handle only): Steps 1, 4, 5 56 + **Custom domain**: Steps 1, 2, 3, 4, 5 57 + 46 58 ### Automated Setup Example 47 59 48 60 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). ··· 134 146 - Updates `aggregator-config.env` with record URI and CID 135 147 - Prints record details 136 148 149 + ### 5-create-api-key.sh 150 + 151 + **Purpose**: Generates an API key for aggregator authentication 152 + 153 + **Prerequisites**: 154 + - Steps 1-4 completed 155 + - Aggregator indexed by Coves (usually takes a few seconds after step 4) 156 + - Web browser for OAuth login 157 + 158 + **What it does**: 159 + 1. Guides you through OAuth login in your browser 160 + 2. Provides the JavaScript to call the `createApiKey` endpoint 161 + 3. Validates the API key format 162 + 4. Saves the key to your config file 163 + 164 + **Outputs**: 165 + - Updates `aggregator-config.env` with `COVES_API_KEY` 166 + - Provides instructions for updating your `.env` file 167 + 168 + **Important Notes**: 169 + - The API key is shown **ONCE** and cannot be retrieved later 170 + - API keys replace password-based authentication 171 + - Keys can be revoked and regenerated at any time 172 + - Store securely - never commit to version control 173 + 137 174 ## Configuration File 138 175 139 - After running the scripts, you'll have an `aggregator-config.env` file with: 176 + After running all scripts, you'll have an `aggregator-config.env` file with: 140 177 141 178 ```bash 179 + # Identity 142 180 AGGREGATOR_DID="did:plc:..." 143 - AGGREGATOR_HANDLE="mynewsbot.bsky.social" 181 + AGGREGATOR_HANDLE="mynewsbot.example.com" 144 182 AGGREGATOR_PDS_URL="https://bsky.social" 145 - AGGREGATOR_EMAIL="bot@example.com" 146 - AGGREGATOR_PASSWORD="..." 147 - AGGREGATOR_ACCESS_JWT="..." 148 - AGGREGATOR_REFRESH_JWT="..." 149 - AGGREGATOR_DOMAIN="rss-bot.example.com" 150 - COVES_INSTANCE_URL="https://api.coves.social" 183 + AGGREGATOR_DOMAIN="mynewsbot.example.com" 184 + 185 + # Coves Instance 186 + COVES_INSTANCE_URL="https://coves.social" 151 187 SERVICE_DECLARATION_URI="at://did:plc:.../social.coves.aggregator.service/self" 152 188 SERVICE_DECLARATION_CID="..." 189 + 190 + # API Key (from Step 5) 191 + COVES_API_KEY="ckapi_..." 153 192 ``` 154 193 155 - **Use this in your aggregator code** to authenticate and post. 194 + **For your aggregator's `.env` file, you only need:** 195 + 196 + ```bash 197 + COVES_API_KEY=ckapi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 198 + COVES_API_URL=https://coves.social 199 + ``` 156 200 157 201 ## What Happens Next? 158 202 159 - After completing all 4 steps: 203 + After completing all 5 steps: 160 204 161 205 1. **Your aggregator is registered** in the Coves instance's `users` table 162 206 2. **Your service declaration is indexed** in the `aggregators` table (takes a few seconds) 163 - 3. **Community moderators can now authorize** your aggregator for their communities 164 - 4. **Once authorized**, your aggregator can post to those communities 207 + 3. **Your API key is stored** and can be used for authentication 208 + 4. **Community moderators can authorize** your aggregator for their communities 209 + 5. **Your aggregator can post** to authorized communities (or all if you're a trusted aggregator) 165 210 166 211 ## Creating an Authorization 167 212 ··· 171 216 172 217 ## Posting to Communities 173 218 174 - Once authorized, your aggregator can post using: 219 + Once authorized, your aggregator can post using your API key: 175 220 176 221 ```bash 177 - curl -X POST https://api.coves.social/xrpc/social.coves.community.post.create \ 178 - -H "Authorization: DPoP $AGGREGATOR_ACCESS_JWT" \ 222 + curl -X POST https://coves.social/xrpc/social.coves.community.post.create \ 223 + -H "Authorization: Bearer $COVES_API_KEY" \ 179 224 -H "Content-Type: application/json" \ 180 225 -d '{ 181 - "communityDid": "did:plc:...", 182 - "post": { 183 - "text": "Your post content", 184 - "createdAt": "2024-01-15T12:00:00Z" 185 - } 226 + "community": "c-worldnews.coves.social", 227 + "content": "Your post content", 228 + "facets": [] 186 229 }' 187 230 ``` 188 231 232 + The API key handles all authentication - no OAuth token refresh needed. 233 + 189 234 ## Troubleshooting 190 235 191 236 ### Error: "DomainVerificationFailed"
+200
scripts/setup_dev_aggregator.go
··· 1 + // setup_dev_aggregator.go - Creates a local test aggregator on the local PDS 2 + // 3 + // This script creates an aggregator account on the local PDS for development testing. 4 + // After running, you'll need to: 5 + // 1. Register the aggregator via OAuth UI 6 + // 2. Generate an API key via the createApiKey endpoint 7 + // 8 + // Usage: go run scripts/setup_dev_aggregator.go 9 + package main 10 + 11 + import ( 12 + "bytes" 13 + "context" 14 + "database/sql" 15 + "encoding/json" 16 + "fmt" 17 + "io" 18 + "log" 19 + "net/http" 20 + 21 + _ "github.com/lib/pq" 22 + ) 23 + 24 + const ( 25 + PDSURL = "http://localhost:3001" 26 + DatabaseURL = "postgres://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable" 27 + ) 28 + 29 + type CreateAccountRequest struct { 30 + Email string `json:"email"` 31 + Handle string `json:"handle"` 32 + Password string `json:"password"` 33 + } 34 + 35 + type CreateAccountResponse struct { 36 + DID string `json:"did"` 37 + Handle string `json:"handle"` 38 + AccessJWT string `json:"accessJwt"` 39 + } 40 + 41 + type CreateSessionRequest struct { 42 + Identifier string `json:"identifier"` 43 + Password string `json:"password"` 44 + } 45 + 46 + type CreateSessionResponse struct { 47 + DID string `json:"did"` 48 + Handle string `json:"handle"` 49 + AccessJWT string `json:"accessJwt"` 50 + } 51 + 52 + func main() { 53 + ctx := context.Background() 54 + 55 + // Configuration 56 + handle := "test-aggregator.local.coves.dev" 57 + email := "test-aggregator@example.com" 58 + password := "test-password-12345" 59 + displayName := "Test Aggregator (Dev)" 60 + 61 + log.Printf("Setting up dev aggregator: %s", handle) 62 + 63 + // Connect to database 64 + db, err := sql.Open("postgres", DatabaseURL) 65 + if err != nil { 66 + log.Fatalf("Failed to connect to database: %v", err) 67 + } 68 + defer db.Close() 69 + 70 + // Step 1: Try to create account on PDS (or get existing session) 71 + log.Printf("Creating account on PDS: %s", PDSURL) 72 + 73 + var did string 74 + 75 + // First try to create account 76 + createResp, err := createAccount(handle, email, password) 77 + if err != nil { 78 + log.Printf("Account creation failed (may already exist): %v", err) 79 + log.Printf("Trying to create session with existing account...") 80 + 81 + // Try to login instead 82 + sessionResp, err := createSession(handle, password) 83 + if err != nil { 84 + log.Fatalf("Failed to create session: %v", err) 85 + } 86 + did = sessionResp.DID 87 + log.Printf("Logged in as existing account: %s", did) 88 + } else { 89 + did = createResp.DID 90 + log.Printf("Created new account: %s", did) 91 + } 92 + 93 + // Step 2: Check if already in users table 94 + var existingHandle string 95 + err = db.QueryRowContext(ctx, "SELECT handle FROM users WHERE did = $1", did).Scan(&existingHandle) 96 + if err == nil { 97 + log.Printf("User already exists in users table: %s", existingHandle) 98 + } else { 99 + // Insert into users table 100 + log.Printf("Inserting user into users table...") 101 + _, err = db.ExecContext(ctx, ` 102 + INSERT INTO users (did, handle, pds_url) 103 + VALUES ($1, $2, $3) 104 + ON CONFLICT (did) DO UPDATE SET handle = $2 105 + `, did, handle, PDSURL) 106 + if err != nil { 107 + log.Fatalf("Failed to insert user: %v", err) 108 + } 109 + } 110 + 111 + // Step 3: Check if already in aggregators table 112 + var existingAggDID string 113 + err = db.QueryRowContext(ctx, "SELECT did FROM aggregators WHERE did = $1", did).Scan(&existingAggDID) 114 + if err == nil { 115 + log.Printf("Aggregator already exists in aggregators table") 116 + } else { 117 + // Insert into aggregators table 118 + log.Printf("Inserting aggregator into aggregators table...") 119 + recordURI := fmt.Sprintf("at://%s/social.coves.aggregator.declaration/self", did) 120 + recordCID := "dev-placeholder-cid" 121 + 122 + _, err = db.ExecContext(ctx, ` 123 + INSERT INTO aggregators (did, display_name, description, record_uri, record_cid, created_at, indexed_at) 124 + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) 125 + `, did, displayName, "Development test aggregator", recordURI, recordCID) 126 + if err != nil { 127 + log.Fatalf("Failed to insert aggregator: %v", err) 128 + } 129 + } 130 + 131 + fmt.Println() 132 + fmt.Println("========================================") 133 + fmt.Println(" DEV AGGREGATOR ACCOUNT CREATED") 134 + fmt.Println("========================================") 135 + fmt.Println() 136 + fmt.Printf(" DID: %s\n", did) 137 + fmt.Printf(" Handle: %s\n", handle) 138 + fmt.Printf(" Password: %s\n", password) 139 + fmt.Println() 140 + fmt.Println(" Next steps:") 141 + fmt.Println(" 1. Start Coves server: make run") 142 + fmt.Println(" 2. Authenticate as this account via OAuth UI") 143 + fmt.Println(" 3. Call POST /xrpc/social.coves.aggregator.createApiKey") 144 + fmt.Println(" 4. Save the API key and add to aggregators/kagi-news/.env") 145 + fmt.Println() 146 + fmt.Println("========================================") 147 + } 148 + 149 + func createAccount(handle, email, password string) (*CreateAccountResponse, error) { 150 + reqBody := CreateAccountRequest{ 151 + Email: email, 152 + Handle: handle, 153 + Password: password, 154 + } 155 + 156 + body, _ := json.Marshal(reqBody) 157 + resp, err := http.Post(PDSURL+"/xrpc/com.atproto.server.createAccount", "application/json", bytes.NewReader(body)) 158 + if err != nil { 159 + return nil, fmt.Errorf("request failed: %w", err) 160 + } 161 + defer resp.Body.Close() 162 + 163 + respBody, _ := io.ReadAll(resp.Body) 164 + if resp.StatusCode != http.StatusOK { 165 + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) 166 + } 167 + 168 + var result CreateAccountResponse 169 + if err := json.Unmarshal(respBody, &result); err != nil { 170 + return nil, fmt.Errorf("failed to parse response: %w", err) 171 + } 172 + 173 + return &result, nil 174 + } 175 + 176 + func createSession(identifier, password string) (*CreateSessionResponse, error) { 177 + reqBody := CreateSessionRequest{ 178 + Identifier: identifier, 179 + Password: password, 180 + } 181 + 182 + body, _ := json.Marshal(reqBody) 183 + resp, err := http.Post(PDSURL+"/xrpc/com.atproto.server.createSession", "application/json", bytes.NewReader(body)) 184 + if err != nil { 185 + return nil, fmt.Errorf("request failed: %w", err) 186 + } 187 + defer resp.Body.Close() 188 + 189 + respBody, _ := io.ReadAll(resp.Body) 190 + if resp.StatusCode != http.StatusOK { 191 + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) 192 + } 193 + 194 + var result CreateSessionResponse 195 + if err := json.Unmarshal(respBody, &result); err != nil { 196 + return nil, fmt.Errorf("failed to parse response: %w", err) 197 + } 198 + 199 + return &result, nil 200 + }
+2 -1
aggregators/kagi-news/crontab
··· 1 1 # Run Kagi News aggregator daily at 1 PM UTC (after Kagi updates around noon) 2 - 0 13 * * * cd /app && /usr/local/bin/python -m src.main >> /var/log/cron.log 2>&1 2 + # Source environment variables exported by docker-entrypoint.sh 3 + 0 13 * * * . /etc/environment; cd /app && /usr/local/bin/python -m src.main >> /var/log/cron.log 2>&1 3 4 4 5 # Blank line required at end of crontab 5 6
+1 -1
internal/atproto/lexicon/social/coves/embed/external.json
··· 38 38 "thumb": { 39 39 "type": "blob", 40 40 "accept": ["image/png", "image/jpeg", "image/webp"], 41 - "maxSize": 1000000, 41 + "maxSize": 6000000, 42 42 "description": "Thumbnail image for the post (applies to primary link)" 43 43 }, 44 44 "domain": {
+6 -6
internal/core/blobs/service.go
··· 90 90 return nil, fmt.Errorf("failed to read image data: %w", err) 91 91 } 92 92 93 - // Validate size (1MB = 1048576 bytes) 94 - const maxSize = 1048576 93 + // Validate size (6MB = 6291456 bytes) 94 + const maxSize = 6291456 95 95 if len(data) > maxSize { 96 - return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize) 96 + return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize) 97 97 } 98 98 99 99 // Upload to PDS ··· 124 124 return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType) 125 125 } 126 126 127 - // Validate size (1MB = 1048576 bytes) 128 - const maxSize = 1048576 127 + // Validate size (6MB = 6291456 bytes) 128 + const maxSize = 6291456 129 129 if len(data) > maxSize { 130 - return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize) 130 + return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize) 131 131 } 132 132 133 133 // Use community's PDS URL (for federated communities)
+5 -1
internal/db/postgres/discover_repo.go
··· 5 5 "context" 6 6 "database/sql" 7 7 "fmt" 8 + "time" 8 9 ) 9 10 10 11 type postgresDiscoverRepo struct { ··· 33 34 34 35 // GetDiscover retrieves posts from ALL communities (public feed) 35 36 func (r *postgresDiscoverRepo) GetDiscover(ctx context.Context, req discover.GetDiscoverRequest) ([]*discover.FeedViewPost, *string, error) { 37 + // Capture query time for stable cursor generation (used for hot sort pagination) 38 + queryTime := time.Now() 39 + 36 40 // Build ORDER BY clause based on sort type 37 41 orderBy, timeFilter := r.buildSortClause(req.Sort, req.Timeframe) 38 42 ··· 119 123 hotRanks = hotRanks[:req.Limit] 120 124 lastPost := feedPosts[len(feedPosts)-1].Post 121 125 lastHotRank := hotRanks[len(hotRanks)-1] 122 - cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank) 126 + cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank, queryTime) 123 127 cursor = &cursorStr 124 128 } 125 129
+5 -1
internal/db/postgres/feed_repo.go
··· 5 5 "context" 6 6 "database/sql" 7 7 "fmt" 8 + "time" 8 9 ) 9 10 10 11 type postgresFeedRepo struct { ··· 37 38 // GetCommunityFeed retrieves posts from a community with sorting and pagination 38 39 // Single query with JOINs for optimal performance 39 40 func (r *postgresFeedRepo) GetCommunityFeed(ctx context.Context, req communityFeeds.GetCommunityFeedRequest) ([]*communityFeeds.FeedViewPost, *string, error) { 41 + // Capture query time for stable cursor generation (used for hot sort pagination) 42 + queryTime := time.Now() 43 + 40 44 // Build ORDER BY clause based on sort type 41 45 orderBy, timeFilter := r.feedRepoBase.buildSortClause(req.Sort, req.Timeframe) 42 46 ··· 125 129 hotRanks = hotRanks[:req.Limit] 126 130 lastPost := feedPosts[len(feedPosts)-1].Post 127 131 lastHotRank := hotRanks[len(hotRanks)-1] 128 - cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank) 132 + cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank, queryTime) 129 133 cursor = &cursorStr 130 134 } 131 135
+28 -17
internal/db/postgres/feed_repo_base.go
··· 192 192 return filter, []interface{}{score, createdAt, uri}, nil 193 193 194 194 case "hot": 195 - // Cursor format: hot_rank::timestamp::uri 196 - // CRITICAL: Must use computed hot_rank, not raw score, to prevent pagination bugs 197 - if len(payloadParts) != 3 { 195 + // Cursor format: hot_rank::post_created_at::uri::cursor_timestamp 196 + // CRITICAL: cursor_timestamp is when the cursor was created, used for stable hot_rank comparison 197 + // This prevents pagination bugs caused by hot_rank drift when NOW() changes between requests 198 + if len(payloadParts) != 4 { 198 199 return "", nil, fmt.Errorf("invalid cursor format for hot sort") 199 200 } 200 201 201 202 hotRankStr := payloadParts[0] 202 - createdAt := payloadParts[1] 203 + postCreatedAt := payloadParts[1] 203 204 uri := payloadParts[2] 205 + cursorTimestamp := payloadParts[3] 204 206 205 207 // Validate hot_rank is numeric (float) 206 208 hotRank := 0.0 ··· 208 210 return "", nil, fmt.Errorf("invalid cursor hot rank") 209 211 } 210 212 211 - // Validate timestamp format 212 - if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil { 213 - return "", nil, fmt.Errorf("invalid cursor timestamp") 213 + // Validate post timestamp format 214 + if _, err := time.Parse(time.RFC3339Nano, postCreatedAt); err != nil { 215 + return "", nil, fmt.Errorf("invalid cursor post timestamp") 214 216 } 215 217 216 218 // Validate URI format (must be AT-URI) ··· 218 220 return "", nil, fmt.Errorf("invalid cursor URI") 219 221 } 220 222 221 - // CRITICAL: Compare against the computed hot_rank expression, not p.score 222 - filter := fmt.Sprintf(`AND ((%s < $%d OR (%s = $%d AND p.created_at < $%d) OR (%s = $%d AND p.created_at = $%d AND p.uri < $%d)) AND p.uri != $%d)`, 223 - r.hotRankExpression, paramOffset, 224 - r.hotRankExpression, paramOffset, paramOffset+1, 225 - r.hotRankExpression, paramOffset, paramOffset+1, paramOffset+2, 223 + // Validate cursor timestamp format 224 + if _, err := time.Parse(time.RFC3339Nano, cursorTimestamp); err != nil { 225 + return "", nil, fmt.Errorf("invalid cursor timestamp") 226 + } 227 + 228 + // CRITICAL: Use cursor_timestamp instead of NOW() for stable hot_rank comparison 229 + // This ensures posts don't drift across page boundaries due to time passing 230 + stableHotRankExpr := fmt.Sprintf( 231 + `((p.score + 1) / POWER(EXTRACT(EPOCH FROM ($%d::timestamptz - p.created_at))/3600 + 2, 1.5))`, 226 232 paramOffset+3) 227 - return filter, []interface{}{hotRank, createdAt, uri, uri}, nil 233 + 234 + // Use tuple comparison for clean keyset pagination: (hot_rank, created_at, uri) < (cursor_values) 235 + filter := fmt.Sprintf(`AND ((%s, p.created_at, p.uri) < ($%d, $%d, $%d))`, 236 + stableHotRankExpr, paramOffset, paramOffset+1, paramOffset+2) 237 + return filter, []interface{}{hotRank, postCreatedAt, uri, cursorTimestamp}, nil 228 238 229 239 default: 230 240 return "", nil, nil ··· 233 243 234 244 // buildCursor creates HMAC-signed pagination cursor from last post 235 245 // SECURITY: Cursor is signed with HMAC-SHA256 to prevent manipulation 236 - func (r *feedRepoBase) buildCursor(post *posts.PostView, sort string, hotRank float64) string { 246 + // queryTime is the timestamp when the query was executed, used for stable hot_rank comparison 247 + func (r *feedRepoBase) buildCursor(post *posts.PostView, sort string, hotRank float64, queryTime time.Time) string { 237 248 var payload string 238 249 // Use :: as delimiter following Bluesky convention 239 250 const delimiter = "::" ··· 252 263 payload = fmt.Sprintf("%d%s%s%s%s", score, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI) 253 264 254 265 case "hot": 255 - // Format: hot_rank::timestamp::uri 256 - // CRITICAL: Use computed hot_rank with full precision 266 + // Format: hot_rank::post_created_at::uri::cursor_timestamp 267 + // CRITICAL: Include cursor_timestamp for stable hot_rank comparison across requests 257 268 hotRankStr := strconv.FormatFloat(hotRank, 'g', -1, 64) 258 - payload = fmt.Sprintf("%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI) 269 + payload = fmt.Sprintf("%s%s%s%s%s%s%s", hotRankStr, delimiter, post.CreatedAt.Format(time.RFC3339Nano), delimiter, post.URI, delimiter, queryTime.Format(time.RFC3339Nano)) 259 270 260 271 default: 261 272 payload = post.URI
+5 -1
internal/db/postgres/timeline_repo.go
··· 5 5 "context" 6 6 "database/sql" 7 7 "fmt" 8 + "time" 8 9 ) 9 10 10 11 type postgresTimelineRepo struct { ··· 35 36 // GetTimeline retrieves posts from all communities the user subscribes to 36 37 // Single query with JOINs for optimal performance 37 38 func (r *postgresTimelineRepo) GetTimeline(ctx context.Context, req timeline.GetTimelineRequest) ([]*timeline.FeedViewPost, *string, error) { 39 + // Capture query time for stable cursor generation (used for hot sort pagination) 40 + queryTime := time.Now() 41 + 38 42 // Build ORDER BY clause based on sort type 39 43 orderBy, timeFilter := r.buildSortClause(req.Sort, req.Timeframe) 40 44 ··· 125 129 hotRanks = hotRanks[:req.Limit] 126 130 lastPost := feedPosts[len(feedPosts)-1].Post 127 131 lastHotRank := hotRanks[len(hotRanks)-1] 128 - cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank) 132 + cursorStr := r.feedRepoBase.buildCursor(lastPost, req.Sort, lastHotRank, queryTime) 129 133 cursor = &cursorStr 130 134 } 131 135
+16 -43
internal/atproto/jetstream/user_consumer.go
··· 5 5 "Coves/internal/core/users" 6 6 "context" 7 7 "encoding/json" 8 + "errors" 8 9 "fmt" 9 10 "log" 10 11 "sync" ··· 213 214 } 214 215 215 216 // handleIdentityEvent processes identity events (handle changes) 217 + // NOTE: This only UPDATES existing users - it does NOT create new users. 218 + // Users are created during OAuth login or signup, not from Jetstream events. 219 + // This prevents indexing millions of Bluesky users who never interact with Coves. 216 220 func (c *UserEventConsumer) handleIdentityEvent(ctx context.Context, event *JetstreamEvent) error { 217 221 if event.Identity == nil { 218 222 return fmt.Errorf("identity event missing identity data") ··· 225 229 return fmt.Errorf("identity event missing did or handle") 226 230 } 227 231 228 - log.Printf("Identity event: %s โ†’ %s", did, handle) 229 - 230 - // Get existing user to check if handle changed 232 + // Only process users who exist in our database (i.e., have used Coves before) 231 233 existingUser, err := c.userService.GetUserByDID(ctx, did) 232 234 if err != nil { 233 - // User doesn't exist - create new user 234 - pdsURL := "https://bsky.social" // Default Bluesky PDS 235 - // TODO: Resolve PDS URL from DID document via PLC directory 236 - 237 - _, createErr := c.userService.CreateUser(ctx, users.CreateUserRequest{ 238 - DID: did, 239 - Handle: handle, 240 - PDSURL: pdsURL, 241 - }) 242 - 243 - if createErr != nil && !isDuplicateError(createErr) { 244 - return fmt.Errorf("failed to create user: %w", createErr) 235 + if errors.Is(err, users.ErrUserNotFound) { 236 + // User doesn't exist in our database - skip this event 237 + // They'll be indexed when they actually interact with Coves (OAuth login, signup, etc.) 238 + // This prevents us from indexing millions of Bluesky users we don't care about 239 + return nil 245 240 } 246 - 247 - log.Printf("Indexed new user: %s (%s)", handle, did) 248 - return nil 241 + // Database error - propagate so it can be retried 242 + return fmt.Errorf("failed to check if user exists: %w", err) 249 243 } 250 244 245 + log.Printf("Identity event for known user: %s (%s)", handle, did) 246 + 251 247 // User exists - check if handle changed 252 248 if existingUser.Handle != handle { 253 249 log.Printf("Handle changed: %s โ†’ %s (DID: %s)", existingUser.Handle, handle, did) ··· 298 294 return fmt.Errorf("account event missing did") 299 295 } 300 296 301 - // Account events don't include handle, so we can't index yet 302 - // We'll wait for the corresponding identity event 303 - log.Printf("Account event for %s (waiting for identity event)", did) 297 + // Account events don't include handle, so we skip them. 298 + // Users are indexed via OAuth login or signup, not from account events. 304 299 return nil 305 300 } 306 - 307 - // isDuplicateError checks if error is due to duplicate DID/handle 308 - func isDuplicateError(err error) bool { 309 - if err == nil { 310 - return false 311 - } 312 - errStr := err.Error() 313 - return contains(errStr, "already exists") || contains(errStr, "already taken") || contains(errStr, "duplicate") 314 - } 315 - 316 - func contains(s, substr string) bool { 317 - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && anySubstring(s, substr)) 318 - } 319 - 320 - func anySubstring(s, substr string) bool { 321 - for i := 0; i <= len(s)-len(substr); i++ { 322 - if s[i:i+len(substr)] == substr { 323 - return true 324 - } 325 - } 326 - return false 327 - }
+13 -1
internal/core/users/errors.go
··· 1 1 package users 2 2 3 - import "fmt" 3 + import ( 4 + "errors" 5 + "fmt" 6 + ) 7 + 8 + // Sentinel errors for common user operations 9 + var ( 10 + // ErrUserNotFound is returned when a user lookup finds no matching record 11 + ErrUserNotFound = errors.New("user not found") 12 + 13 + // ErrHandleAlreadyTaken is returned when attempting to use a handle that belongs to another user 14 + ErrHandleAlreadyTaken = errors.New("handle already taken") 15 + ) 4 16 5 17 // Domain errors for user service operations 6 18 // These map to lexicon error types defined in social.coves.actor.signup
+94
internal/api/handlers/actor/errors.go
··· 1 + package actor 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + 10 + "Coves/internal/core/posts" 11 + ) 12 + 13 + // ErrorResponse represents an XRPC error response 14 + type ErrorResponse struct { 15 + Error string `json:"error"` 16 + Message string `json:"message"` 17 + } 18 + 19 + // writeError writes a JSON error response 20 + func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 21 + w.Header().Set("Content-Type", "application/json") 22 + w.WriteHeader(statusCode) 23 + if err := json.NewEncoder(w).Encode(ErrorResponse{ 24 + Error: errorType, 25 + Message: message, 26 + }); err != nil { 27 + // Log encoding errors but can't send error response (headers already sent) 28 + log.Printf("ERROR: Failed to encode error response: %v", err) 29 + } 30 + } 31 + 32 + // handleServiceError maps service errors to HTTP responses 33 + func handleServiceError(w http.ResponseWriter, err error) { 34 + // Check for handler-level errors first 35 + var actorNotFound *actorNotFoundError 36 + if errors.As(err, &actorNotFound) { 37 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 38 + return 39 + } 40 + 41 + // Check for service-level errors 42 + switch { 43 + case errors.Is(err, posts.ErrNotFound): 44 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 45 + 46 + case errors.Is(err, posts.ErrActorNotFound): 47 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 48 + 49 + case errors.Is(err, posts.ErrCommunityNotFound): 50 + writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found") 51 + 52 + case errors.Is(err, posts.ErrInvalidCursor): 53 + writeError(w, http.StatusBadRequest, "InvalidCursor", "Invalid pagination cursor") 54 + 55 + case posts.IsValidationError(err): 56 + // Extract message from ValidationError for cleaner response 57 + var valErr *posts.ValidationError 58 + if errors.As(err, &valErr) { 59 + writeError(w, http.StatusBadRequest, "InvalidRequest", valErr.Message) 60 + } else { 61 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 62 + } 63 + 64 + default: 65 + // Internal server error - don't leak details 66 + log.Printf("ERROR: Actor posts service error: %v", err) 67 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 68 + } 69 + } 70 + 71 + // actorNotFoundError represents an actor not found error 72 + type actorNotFoundError struct { 73 + actor string 74 + } 75 + 76 + func (e *actorNotFoundError) Error() string { 77 + return fmt.Sprintf("actor not found: %s", e.actor) 78 + } 79 + 80 + // resolutionFailedError represents an infrastructure failure during resolution 81 + // (database down, DNS failures, TLS errors, etc.) 82 + // This is distinct from actorNotFoundError to avoid masking real problems as "not found" 83 + type resolutionFailedError struct { 84 + actor string 85 + cause error 86 + } 87 + 88 + func (e *resolutionFailedError) Error() string { 89 + return fmt.Sprintf("failed to resolve actor %s: %v", e.actor, e.cause) 90 + } 91 + 92 + func (e *resolutionFailedError) Unwrap() error { 93 + return e.cause 94 + }
+185
internal/api/handlers/actor/get_posts.go
··· 1 + package actor 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "log" 7 + "net/http" 8 + "strconv" 9 + "strings" 10 + 11 + "Coves/internal/api/handlers/common" 12 + "Coves/internal/api/middleware" 13 + "Coves/internal/core/blueskypost" 14 + "Coves/internal/core/posts" 15 + "Coves/internal/core/users" 16 + "Coves/internal/core/votes" 17 + ) 18 + 19 + // GetPostsHandler handles actor post retrieval 20 + type GetPostsHandler struct { 21 + postService posts.Service 22 + userService users.UserService 23 + voteService votes.Service 24 + blueskyService blueskypost.Service 25 + } 26 + 27 + // NewGetPostsHandler creates a new actor posts handler 28 + func NewGetPostsHandler( 29 + postService posts.Service, 30 + userService users.UserService, 31 + voteService votes.Service, 32 + blueskyService blueskypost.Service, 33 + ) *GetPostsHandler { 34 + if blueskyService == nil { 35 + log.Printf("[ACTOR-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved") 36 + } 37 + return &GetPostsHandler{ 38 + postService: postService, 39 + userService: userService, 40 + voteService: voteService, 41 + blueskyService: blueskyService, 42 + } 43 + } 44 + 45 + // HandleGetPosts retrieves posts by an actor (user) 46 + // GET /xrpc/social.coves.actor.getPosts?actor={did_or_handle}&filter=posts_with_replies&community=...&limit=50&cursor=... 47 + func (h *GetPostsHandler) HandleGetPosts(w http.ResponseWriter, r *http.Request) { 48 + if r.Method != http.MethodGet { 49 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 50 + return 51 + } 52 + 53 + // Parse query parameters 54 + req, err := h.parseRequest(r) 55 + if err != nil { 56 + // Check if it's an actor not found error (from handle resolution) 57 + var actorNotFound *actorNotFoundError 58 + if errors.As(err, &actorNotFound) { 59 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 60 + return 61 + } 62 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 63 + return 64 + } 65 + 66 + // Get viewer DID for populating viewer state (optional) 67 + viewerDID := middleware.GetUserDID(r) 68 + req.ViewerDID = viewerDID 69 + 70 + // Get actor posts from service 71 + response, err := h.postService.GetAuthorPosts(r.Context(), req) 72 + if err != nil { 73 + handleServiceError(w, err) 74 + return 75 + } 76 + 77 + // Populate viewer vote state if authenticated 78 + common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed) 79 + 80 + // Transform blob refs to URLs and resolve post embeds for all posts 81 + for _, feedPost := range response.Feed { 82 + if feedPost.Post != nil { 83 + posts.TransformBlobRefsToURLs(feedPost.Post) 84 + posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService) 85 + } 86 + } 87 + 88 + // Pre-encode response to buffer before writing headers 89 + // This ensures we can return a proper error if encoding fails 90 + responseBytes, err := json.Marshal(response) 91 + if err != nil { 92 + log.Printf("ERROR: Failed to encode actor posts response: %v", err) 93 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 94 + return 95 + } 96 + 97 + // Return feed 98 + w.Header().Set("Content-Type", "application/json") 99 + w.WriteHeader(http.StatusOK) 100 + if _, err := w.Write(responseBytes); err != nil { 101 + log.Printf("ERROR: Failed to write actor posts response: %v", err) 102 + } 103 + } 104 + 105 + // parseRequest parses query parameters into GetAuthorPostsRequest 106 + func (h *GetPostsHandler) parseRequest(r *http.Request) (posts.GetAuthorPostsRequest, error) { 107 + req := posts.GetAuthorPostsRequest{} 108 + 109 + // Required: actor (handle or DID) 110 + actor := r.URL.Query().Get("actor") 111 + if actor == "" { 112 + return req, posts.NewValidationError("actor", "actor parameter is required") 113 + } 114 + // Validate actor length to prevent DoS via massive strings 115 + // Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer) 116 + // Max handle length is 253 chars (DNS limit) 117 + const maxActorLength = 2048 118 + if len(actor) > maxActorLength { 119 + return req, posts.NewValidationError("actor", "actor parameter exceeds maximum length") 120 + } 121 + 122 + // Resolve actor to DID if it's a handle 123 + actorDID, err := h.resolveActor(r, actor) 124 + if err != nil { 125 + return req, err 126 + } 127 + req.ActorDID = actorDID 128 + 129 + // Optional: filter (default: posts_with_replies) 130 + req.Filter = r.URL.Query().Get("filter") 131 + 132 + // Optional: community (handle or DID) 133 + req.Community = r.URL.Query().Get("community") 134 + 135 + // Optional: limit (default: 50, max: 100) 136 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 137 + limit, err := strconv.Atoi(limitStr) 138 + if err != nil { 139 + return req, posts.NewValidationError("limit", "limit must be a valid integer") 140 + } 141 + req.Limit = limit 142 + } 143 + 144 + // Optional: cursor 145 + if cursor := r.URL.Query().Get("cursor"); cursor != "" { 146 + req.Cursor = &cursor 147 + } 148 + 149 + return req, nil 150 + } 151 + 152 + // resolveActor converts an actor identifier (handle or DID) to a DID 153 + func (h *GetPostsHandler) resolveActor(r *http.Request, actor string) (string, error) { 154 + // If it's already a DID, return it 155 + if strings.HasPrefix(actor, "did:") { 156 + return actor, nil 157 + } 158 + 159 + // It's a handle - resolve to DID using user service 160 + did, err := h.userService.ResolveHandleToDID(r.Context(), actor) 161 + if err != nil { 162 + // Check for context errors (timeouts, cancellation) - these are infrastructure errors 163 + if r.Context().Err() != nil { 164 + log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err) 165 + return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()} 166 + } 167 + 168 + // Check for common "not found" patterns in error message 169 + errStr := err.Error() 170 + isNotFound := strings.Contains(errStr, "not found") || 171 + strings.Contains(errStr, "no rows") || 172 + strings.Contains(errStr, "unable to resolve") 173 + 174 + if isNotFound { 175 + return "", &actorNotFoundError{actor: actor} 176 + } 177 + 178 + // For other errors (network, database, DNS failures), return infrastructure error 179 + // This ensures users see "internal error" not "actor not found" for real problems 180 + log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err) 181 + return "", &resolutionFailedError{actor: actor, cause: err} 182 + } 183 + 184 + return did, nil 185 + }
+66
internal/atproto/lexicon/social/coves/actor/getPosts.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.actor.getPosts", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a user's posts for their profile page.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "DID or handle of the user" 16 + }, 17 + "filter": { 18 + "type": "string", 19 + "knownValues": ["posts_with_replies", "posts_no_replies", "posts_with_media"], 20 + "default": "posts_with_replies", 21 + "description": "Filter for post types" 22 + }, 23 + "community": { 24 + "type": "string", 25 + "format": "at-identifier", 26 + "description": "Filter to posts in a specific community" 27 + }, 28 + "limit": { 29 + "type": "integer", 30 + "minimum": 1, 31 + "maximum": 100, 32 + "default": 50 33 + }, 34 + "cursor": { 35 + "type": "string" 36 + } 37 + } 38 + }, 39 + "output": { 40 + "encoding": "application/json", 41 + "schema": { 42 + "type": "object", 43 + "required": ["feed"], 44 + "properties": { 45 + "feed": { 46 + "type": "array", 47 + "items": { 48 + "type": "ref", 49 + "ref": "social.coves.feed.defs#feedViewPost" 50 + } 51 + }, 52 + "cursor": { 53 + "type": "string" 54 + } 55 + } 56 + } 57 + }, 58 + "errors": [ 59 + { 60 + "name": "NotFound", 61 + "description": "Actor not found" 62 + } 63 + ] 64 + } 65 + } 66 + }
+6
internal/core/posts/errors.go
··· 25 25 26 26 // ErrRateLimitExceeded is returned when an aggregator exceeds rate limits 27 27 ErrRateLimitExceeded = errors.New("rate limit exceeded") 28 + 29 + // ErrInvalidCursor is returned when a pagination cursor is malformed 30 + ErrInvalidCursor = errors.New("invalid pagination cursor") 31 + 32 + // ErrActorNotFound is returned when the requested actor does not exist 33 + ErrActorNotFound = errors.New("actor not found") 28 34 ) 29 35 30 36 // ValidationError represents a validation error with field context
+10
internal/core/posts/interfaces.go
··· 16 16 // AppView indexing happens asynchronously via Jetstream consumer 17 17 CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) 18 18 19 + // GetAuthorPosts retrieves posts authored by a specific user for their profile page 20 + // Supports filtering by post type (with/without replies, media only) and community 21 + // Returns paginated feed with cursor 22 + GetAuthorPosts(ctx context.Context, req GetAuthorPostsRequest) (*GetAuthorPostsResponse, error) 23 + 19 24 // Future methods (Beta): 20 25 // GetPost(ctx context.Context, uri string, viewerDID *string) (*Post, error) 21 26 // UpdatePost(ctx context.Context, req UpdatePostRequest) (*Post, error) ··· 34 39 // Used for E2E test verification and future GET endpoint 35 40 GetByURI(ctx context.Context, uri string) (*Post, error) 36 41 42 + // GetByAuthor retrieves posts authored by a specific user 43 + // Supports filtering by post type and community 44 + // Returns posts, cursor for pagination, and error 45 + GetByAuthor(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) 46 + 37 47 // Future methods (Beta): 38 48 // Update(ctx context.Context, post *Post) error 39 49 // Delete(ctx context.Context, uri string) error
+71
internal/core/posts/post.go
··· 143 143 Tags []string `json:"tags,omitempty"` 144 144 Saved bool `json:"saved"` 145 145 } 146 + 147 + // Filter constants for GetAuthorPosts 148 + const ( 149 + FilterPostsWithReplies = "posts_with_replies" 150 + FilterPostsNoReplies = "posts_no_replies" 151 + FilterPostsWithMedia = "posts_with_media" 152 + ) 153 + 154 + // GetAuthorPostsRequest represents input for fetching author's posts 155 + // Matches social.coves.actor.getPosts lexicon input 156 + type GetAuthorPostsRequest struct { 157 + ActorDID string // Resolved DID from actor param (handle or DID) 158 + Filter string // FilterPostsWithReplies, FilterPostsNoReplies, FilterPostsWithMedia 159 + Community string // Optional community DID filter 160 + Limit int // Number of posts to return (1-100, default 50) 161 + Cursor *string // Pagination cursor 162 + ViewerDID string // Viewer's DID for enriching viewer state 163 + } 164 + 165 + // GetAuthorPostsResponse represents author posts response 166 + // Matches social.coves.actor.getPosts lexicon output 167 + type GetAuthorPostsResponse struct { 168 + Feed []*FeedViewPost `json:"feed"` 169 + Cursor *string `json:"cursor,omitempty"` 170 + } 171 + 172 + // FeedViewPost matches social.coves.feed.defs#feedViewPost 173 + // Wraps a post with optional context about why it appears in a feed 174 + type FeedViewPost struct { 175 + Post *PostView `json:"post"` 176 + Reason *FeedReason `json:"reason,omitempty"` // Context for why post appears in feed 177 + Reply *ReplyRef `json:"reply,omitempty"` // Reply context if post is a reply 178 + } 179 + 180 + // GetPost returns the underlying PostView for viewer state enrichment 181 + func (f *FeedViewPost) GetPost() *PostView { 182 + return f.Post 183 + } 184 + 185 + // FeedReason represents the reason a post appears in a feed 186 + // Matches social.coves.feed.defs union type for feed context 187 + type FeedReason struct { 188 + Type string `json:"$type"` 189 + Repost *ReasonRepost `json:"repost,omitempty"` 190 + Pin *ReasonPin `json:"pin,omitempty"` 191 + } 192 + 193 + // ReasonRepost indicates the post was reposted by another user 194 + type ReasonRepost struct { 195 + By *AuthorView `json:"by"` 196 + IndexedAt string `json:"indexedAt"` 197 + } 198 + 199 + // ReasonPin indicates the post is pinned by the community 200 + type ReasonPin struct { 201 + Community *CommunityRef `json:"community"` 202 + } 203 + 204 + // ReplyRef contains context about post replies 205 + // Matches social.coves.feed.defs#replyRef 206 + type ReplyRef struct { 207 + Root *PostRef `json:"root"` 208 + Parent *PostRef `json:"parent"` 209 + } 210 + 211 + // PostRef is a minimal reference to a post (URI + CID) 212 + // Matches social.coves.feed.defs#postRef 213 + type PostRef struct { 214 + URI string `json:"uri"` 215 + CID string `json:"cid"` 216 + }
+277
internal/core/posts/service_author_posts_test.go
··· 1 + package posts 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + ) 7 + 8 + // mockRepository implements Repository for testing 9 + type mockRepository struct { 10 + getByAuthorFunc func(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) 11 + } 12 + 13 + func (m *mockRepository) Create(ctx context.Context, post *Post) error { 14 + return nil 15 + } 16 + 17 + func (m *mockRepository) GetByURI(ctx context.Context, uri string) (*Post, error) { 18 + return nil, nil 19 + } 20 + 21 + func (m *mockRepository) GetByAuthor(ctx context.Context, req GetAuthorPostsRequest) ([]*PostView, *string, error) { 22 + if m.getByAuthorFunc != nil { 23 + return m.getByAuthorFunc(ctx, req) 24 + } 25 + return []*PostView{}, nil, nil 26 + } 27 + 28 + func (m *mockRepository) SoftDelete(ctx context.Context, uri string) error { 29 + return nil 30 + } 31 + 32 + func (m *mockRepository) Update(ctx context.Context, post *Post) error { 33 + return nil 34 + } 35 + 36 + func (m *mockRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error { 37 + return nil 38 + } 39 + 40 + func TestValidateDIDFormat(t *testing.T) { 41 + tests := []struct { 42 + name string 43 + did string 44 + wantErr bool 45 + errMsg string 46 + }{ 47 + { 48 + name: "valid did:plc", 49 + did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 50 + wantErr: false, 51 + }, 52 + { 53 + name: "valid did:web", 54 + did: "did:web:example.com", 55 + wantErr: false, 56 + }, 57 + { 58 + name: "valid did:web with subdomain", 59 + did: "did:web:bsky.social", 60 + wantErr: false, 61 + }, 62 + { 63 + name: "valid did:web localhost", 64 + did: "did:web:localhost", 65 + wantErr: false, 66 + }, 67 + { 68 + name: "invalid - missing method", 69 + did: "did:", 70 + wantErr: true, 71 + errMsg: "unsupported DID method", 72 + }, 73 + { 74 + name: "invalid - unsupported method", 75 + did: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 76 + wantErr: true, 77 + errMsg: "unsupported DID method", 78 + }, 79 + { 80 + name: "invalid did:plc - empty identifier", 81 + did: "did:plc:", 82 + wantErr: true, 83 + errMsg: "missing identifier", 84 + }, 85 + { 86 + name: "invalid did:plc - uppercase chars", 87 + did: "did:plc:UPPERCASE", 88 + wantErr: true, 89 + errMsg: "invalid characters", 90 + }, 91 + { 92 + name: "invalid did:plc - numbers outside base32", 93 + did: "did:plc:abc0189", 94 + wantErr: true, 95 + errMsg: "invalid characters", 96 + }, 97 + { 98 + name: "invalid did:web - empty domain", 99 + did: "did:web:", 100 + wantErr: true, 101 + errMsg: "missing domain", 102 + }, 103 + { 104 + name: "invalid did:web - no dot in domain", 105 + did: "did:web:nodot", 106 + wantErr: true, 107 + errMsg: "invalid domain", 108 + }, 109 + { 110 + name: "invalid - not a DID", 111 + did: "notadid", 112 + wantErr: true, 113 + errMsg: "unsupported DID method", 114 + }, 115 + { 116 + name: "invalid - too long", 117 + did: "did:plc:" + string(make([]byte, 2100)), 118 + wantErr: true, 119 + errMsg: "exceeds maximum length", 120 + }, 121 + } 122 + 123 + for _, tt := range tests { 124 + t.Run(tt.name, func(t *testing.T) { 125 + err := validateDIDFormat(tt.did) 126 + if tt.wantErr { 127 + if err == nil { 128 + t.Errorf("validateDIDFormat(%q) = nil, want error containing %q", tt.did, tt.errMsg) 129 + } else if tt.errMsg != "" && !testContains(err.Error(), tt.errMsg) { 130 + t.Errorf("validateDIDFormat(%q) = %v, want error containing %q", tt.did, err, tt.errMsg) 131 + } 132 + } else { 133 + if err != nil { 134 + t.Errorf("validateDIDFormat(%q) = %v, want nil", tt.did, err) 135 + } 136 + } 137 + }) 138 + } 139 + } 140 + 141 + // helper function for contains check (named testContains to avoid conflict with package function) 142 + func testContains(s, substr string) bool { 143 + for i := 0; i <= len(s)-len(substr); i++ { 144 + if s[i:i+len(substr)] == substr { 145 + return true 146 + } 147 + } 148 + return false 149 + } 150 + 151 + func TestValidateGetAuthorPostsRequest(t *testing.T) { 152 + // Create a minimal service for testing validation 153 + // We only need to test the validation logic, not the full service 154 + 155 + tests := []struct { 156 + name string 157 + req GetAuthorPostsRequest 158 + wantErr bool 159 + errMsg string 160 + }{ 161 + { 162 + name: "valid request - minimal", 163 + req: GetAuthorPostsRequest{ 164 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 165 + }, 166 + wantErr: false, 167 + }, 168 + { 169 + name: "valid request - with filter", 170 + req: GetAuthorPostsRequest{ 171 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 172 + Filter: FilterPostsWithMedia, 173 + }, 174 + wantErr: false, 175 + }, 176 + { 177 + name: "valid request - with limit", 178 + req: GetAuthorPostsRequest{ 179 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 180 + Limit: 25, 181 + }, 182 + wantErr: false, 183 + }, 184 + { 185 + name: "invalid - empty actor", 186 + req: GetAuthorPostsRequest{ 187 + ActorDID: "", 188 + }, 189 + wantErr: true, 190 + errMsg: "actor is required", 191 + }, 192 + { 193 + name: "invalid - bad DID format", 194 + req: GetAuthorPostsRequest{ 195 + ActorDID: "notadid", 196 + }, 197 + wantErr: true, 198 + errMsg: "unsupported DID method", 199 + }, 200 + { 201 + name: "invalid - unknown filter", 202 + req: GetAuthorPostsRequest{ 203 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 204 + Filter: "unknown_filter", 205 + }, 206 + wantErr: true, 207 + errMsg: "filter must be one of", 208 + }, 209 + } 210 + 211 + for _, tt := range tests { 212 + t.Run(tt.name, func(t *testing.T) { 213 + // Create service with nil dependencies - we only test validation 214 + s := &postService{} 215 + err := s.validateGetAuthorPostsRequest(&tt.req) 216 + 217 + if tt.wantErr { 218 + if err == nil { 219 + t.Errorf("validateGetAuthorPostsRequest() = nil, want error containing %q", tt.errMsg) 220 + } else if tt.errMsg != "" && !testContains(err.Error(), tt.errMsg) { 221 + t.Errorf("validateGetAuthorPostsRequest() = %v, want error containing %q", err, tt.errMsg) 222 + } 223 + } else { 224 + if err != nil { 225 + t.Errorf("validateGetAuthorPostsRequest() = %v, want nil", err) 226 + } 227 + } 228 + }) 229 + } 230 + } 231 + 232 + func TestValidateGetAuthorPostsRequest_DefaultsSet(t *testing.T) { 233 + s := &postService{} 234 + 235 + // Test that defaults are set 236 + t.Run("filter defaults to posts_with_replies", func(t *testing.T) { 237 + req := GetAuthorPostsRequest{ 238 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 239 + Filter: "", // empty 240 + } 241 + err := s.validateGetAuthorPostsRequest(&req) 242 + if err != nil { 243 + t.Fatalf("unexpected error: %v", err) 244 + } 245 + if req.Filter != FilterPostsWithReplies { 246 + t.Errorf("Filter = %q, want %q", req.Filter, FilterPostsWithReplies) 247 + } 248 + }) 249 + 250 + t.Run("limit defaults to 50 when 0", func(t *testing.T) { 251 + req := GetAuthorPostsRequest{ 252 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 253 + Limit: 0, 254 + } 255 + err := s.validateGetAuthorPostsRequest(&req) 256 + if err != nil { 257 + t.Fatalf("unexpected error: %v", err) 258 + } 259 + if req.Limit != 50 { 260 + t.Errorf("Limit = %d, want 50", req.Limit) 261 + } 262 + }) 263 + 264 + t.Run("limit capped at 100", func(t *testing.T) { 265 + req := GetAuthorPostsRequest{ 266 + ActorDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 267 + Limit: 200, 268 + } 269 + err := s.validateGetAuthorPostsRequest(&req) 270 + if err != nil { 271 + t.Fatalf("unexpected error: %v", err) 272 + } 273 + if req.Limit != 100 { 274 + t.Errorf("Limit = %d, want 100", req.Limit) 275 + } 276 + }) 277 + }
+12
internal/db/migrations/026_add_author_posts_index.sql
··· 1 + -- +goose Up 2 + -- +goose NO TRANSACTION 3 + -- Add optimized index for author posts queries with soft delete filter 4 + -- This supports the social.coves.actor.getPosts endpoint which retrieves posts by author 5 + -- The existing idx_posts_author doesn't filter deleted posts, causing full index scans 6 + CREATE INDEX CONCURRENTLY idx_posts_author_created 7 + ON posts(author_did, created_at DESC) 8 + WHERE deleted_at IS NULL; 9 + 10 + -- +goose Down 11 + -- +goose NO TRANSACTION 12 + DROP INDEX CONCURRENTLY IF EXISTS idx_posts_author_created;
+285
internal/db/postgres/post_repo.go
··· 4 4 "Coves/internal/core/posts" 5 5 "context" 6 6 "database/sql" 7 + "encoding/base64" 8 + "encoding/json" 7 9 "fmt" 10 + "log" 8 11 "strings" 12 + "time" 9 13 ) 10 14 11 15 type postgresPostRepo struct { ··· 128 132 129 133 return &post, nil 130 134 } 135 + 136 + // GetByAuthor retrieves posts by author with filtering and pagination 137 + // Supports filter options: posts_with_replies (default), posts_no_replies, posts_with_media 138 + // Uses cursor-based pagination with created_at + uri for stable ordering 139 + // Returns []*PostView, next cursor, and error 140 + func (r *postgresPostRepo) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) { 141 + // Build WHERE clauses based on filters 142 + whereConditions := []string{ 143 + "p.author_did = $1", 144 + "p.deleted_at IS NULL", 145 + } 146 + args := []interface{}{req.ActorDID} 147 + paramIndex := 2 148 + 149 + // Optional community filter 150 + if req.Community != "" { 151 + whereConditions = append(whereConditions, fmt.Sprintf("p.community_did = $%d", paramIndex)) 152 + args = append(args, req.Community) 153 + paramIndex++ 154 + } 155 + 156 + // Filter by post type 157 + // Design note: Coves architecture separates posts from comments (unlike Bluesky where 158 + // posts can be replies to other posts). The posts_no_replies filter exists for API 159 + // compatibility with Bluesky's getAuthorFeed, but is intentionally a no-op in Coves 160 + // since all Coves posts are top-level (comments are stored in a separate table). 161 + switch req.Filter { 162 + case posts.FilterPostsWithMedia: 163 + whereConditions = append(whereConditions, "p.embed IS NOT NULL") 164 + case posts.FilterPostsNoReplies: 165 + // No-op: All Coves posts are top-level; comments are in the comments table. 166 + // This filter exists for Bluesky API compatibility. 167 + case posts.FilterPostsWithReplies, "": 168 + // Default: return all posts (no additional filter needed) 169 + } 170 + 171 + // Build cursor filter for pagination 172 + cursorFilter, cursorArgs, cursorErr := r.parseAuthorPostsCursor(req.Cursor, paramIndex) 173 + if cursorErr != nil { 174 + return nil, nil, cursorErr 175 + } 176 + if cursorFilter != "" { 177 + whereConditions = append(whereConditions, cursorFilter) 178 + args = append(args, cursorArgs...) 179 + paramIndex += len(cursorArgs) 180 + } 181 + 182 + // Add limit to args 183 + limit := req.Limit 184 + if limit <= 0 { 185 + limit = 50 // default 186 + } 187 + if limit > 100 { 188 + limit = 100 // max 189 + } 190 + args = append(args, limit+1) // +1 to check for next page 191 + 192 + whereClause := strings.Join(whereConditions, " AND ") 193 + 194 + query := fmt.Sprintf(` 195 + SELECT 196 + p.uri, p.cid, p.rkey, 197 + p.author_did, u.handle as author_handle, 198 + p.community_did, c.handle as community_handle, c.name as community_name, c.avatar_cid as community_avatar, c.pds_url as community_pds_url, 199 + p.title, p.content, p.content_facets, p.embed, p.content_labels, 200 + p.created_at, p.edited_at, p.indexed_at, 201 + p.upvote_count, p.downvote_count, p.score, p.comment_count 202 + FROM posts p 203 + INNER JOIN users u ON p.author_did = u.did 204 + INNER JOIN communities c ON p.community_did = c.did 205 + WHERE %s 206 + ORDER BY p.created_at DESC, p.uri DESC 207 + LIMIT $%d 208 + `, whereClause, paramIndex) 209 + 210 + // Execute query 211 + rows, err := r.db.QueryContext(ctx, query, args...) 212 + if err != nil { 213 + return nil, nil, fmt.Errorf("failed to query author posts: %w", err) 214 + } 215 + defer func() { 216 + if err := rows.Close(); err != nil { 217 + log.Printf("WARN: failed to close rows: %v", err) 218 + } 219 + }() 220 + 221 + // Scan results 222 + var postViews []*posts.PostView 223 + for rows.Next() { 224 + postView, err := r.scanAuthorPost(rows) 225 + if err != nil { 226 + return nil, nil, fmt.Errorf("failed to scan author post: %w", err) 227 + } 228 + postViews = append(postViews, postView) 229 + } 230 + 231 + if err := rows.Err(); err != nil { 232 + return nil, nil, fmt.Errorf("error iterating author posts results: %w", err) 233 + } 234 + 235 + // Handle pagination cursor 236 + var cursor *string 237 + if len(postViews) > limit && limit > 0 { 238 + postViews = postViews[:limit] 239 + lastPost := postViews[len(postViews)-1] 240 + cursorStr := r.buildAuthorPostsCursor(lastPost) 241 + cursor = &cursorStr 242 + } 243 + 244 + return postViews, cursor, nil 245 + } 246 + 247 + // parseAuthorPostsCursor decodes pagination cursor for author posts 248 + // Cursor format: base64(created_at|uri) 249 + // Uses simple | delimiter since this is an internal cursor (not signed like feed cursors) 250 + // Returns filter clause, arguments, and error. Error is returned for malformed cursors 251 + // to provide clear feedback rather than silently returning the first page. 252 + func (r *postgresPostRepo) parseAuthorPostsCursor(cursor *string, paramOffset int) (string, []interface{}, error) { 253 + if cursor == nil || *cursor == "" { 254 + return "", nil, nil 255 + } 256 + 257 + // Validate cursor size to prevent DoS via massive base64 strings 258 + const maxCursorSize = 512 259 + if len(*cursor) > maxCursorSize { 260 + return "", nil, fmt.Errorf("%w: cursor exceeds maximum length", posts.ErrInvalidCursor) 261 + } 262 + 263 + // Decode base64 cursor 264 + decoded, err := base64.URLEncoding.DecodeString(*cursor) 265 + if err != nil { 266 + return "", nil, fmt.Errorf("%w: invalid base64 encoding", posts.ErrInvalidCursor) 267 + } 268 + 269 + // Parse cursor: created_at|uri 270 + parts := strings.Split(string(decoded), "|") 271 + if len(parts) != 2 { 272 + return "", nil, fmt.Errorf("%w: malformed cursor format", posts.ErrInvalidCursor) 273 + } 274 + 275 + createdAt := parts[0] 276 + uri := parts[1] 277 + 278 + // Validate timestamp format 279 + if _, err := time.Parse(time.RFC3339Nano, createdAt); err != nil { 280 + return "", nil, fmt.Errorf("%w: invalid timestamp in cursor", posts.ErrInvalidCursor) 281 + } 282 + 283 + // Validate URI format (must be AT-URI) 284 + if !strings.HasPrefix(uri, "at://") { 285 + return "", nil, fmt.Errorf("%w: invalid URI format in cursor", posts.ErrInvalidCursor) 286 + } 287 + 288 + // Use composite key comparison for stable cursor pagination 289 + // (created_at, uri) < (cursor_created_at, cursor_uri) 290 + filter := fmt.Sprintf("(p.created_at < $%d OR (p.created_at = $%d AND p.uri < $%d))", 291 + paramOffset, paramOffset, paramOffset+1) 292 + return filter, []interface{}{createdAt, uri}, nil 293 + } 294 + 295 + // buildAuthorPostsCursor creates pagination cursor from last post 296 + // Cursor format: base64(created_at|uri) 297 + func (r *postgresPostRepo) buildAuthorPostsCursor(post *posts.PostView) string { 298 + cursorStr := fmt.Sprintf("%s|%s", post.CreatedAt.Format(time.RFC3339Nano), post.URI) 299 + return base64.URLEncoding.EncodeToString([]byte(cursorStr)) 300 + } 301 + 302 + // scanAuthorPost scans a database row into a PostView for author posts query 303 + func (r *postgresPostRepo) scanAuthorPost(rows *sql.Rows) (*posts.PostView, error) { 304 + var ( 305 + postView posts.PostView 306 + authorView posts.AuthorView 307 + communityRef posts.CommunityRef 308 + title, content sql.NullString 309 + facets, embed sql.NullString 310 + labelsJSON sql.NullString 311 + editedAt sql.NullTime 312 + communityHandle sql.NullString 313 + communityAvatar sql.NullString 314 + communityPDSURL sql.NullString 315 + ) 316 + 317 + err := rows.Scan( 318 + &postView.URI, &postView.CID, &postView.RKey, 319 + &authorView.DID, &authorView.Handle, 320 + &communityRef.DID, &communityHandle, &communityRef.Name, &communityAvatar, &communityPDSURL, 321 + &title, &content, &facets, &embed, &labelsJSON, 322 + &postView.CreatedAt, &editedAt, &postView.IndexedAt, 323 + &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, 324 + ) 325 + if err != nil { 326 + return nil, err 327 + } 328 + 329 + // Build author view 330 + postView.Author = &authorView 331 + 332 + // Build community ref 333 + if communityHandle.Valid { 334 + communityRef.Handle = communityHandle.String 335 + } 336 + if communityAvatar.Valid { 337 + communityRef.Avatar = &communityAvatar.String 338 + } 339 + if communityPDSURL.Valid { 340 + communityRef.PDSURL = communityPDSURL.String 341 + } 342 + postView.Community = &communityRef 343 + 344 + // Set optional fields 345 + if title.Valid { 346 + postView.Title = &title.String 347 + } 348 + if content.Valid { 349 + postView.Text = &content.String 350 + } 351 + if editedAt.Valid { 352 + postView.EditedAt = &editedAt.Time 353 + } 354 + 355 + // Parse facets JSON 356 + if facets.Valid { 357 + var facetArray []interface{} 358 + if err := json.Unmarshal([]byte(facets.String), &facetArray); err != nil { 359 + return nil, fmt.Errorf("failed to parse facets JSON for post %s: %w", postView.URI, err) 360 + } 361 + postView.TextFacets = facetArray 362 + } 363 + 364 + // Parse embed JSON 365 + if embed.Valid { 366 + var embedData interface{} 367 + if err := json.Unmarshal([]byte(embed.String), &embedData); err != nil { 368 + return nil, fmt.Errorf("failed to parse embed JSON for post %s: %w", postView.URI, err) 369 + } 370 + postView.Embed = embedData 371 + } 372 + 373 + // Build stats 374 + postView.Stats = &posts.PostStats{ 375 + Upvotes: postView.UpvoteCount, 376 + Downvotes: postView.DownvoteCount, 377 + Score: postView.Score, 378 + CommentCount: postView.CommentCount, 379 + } 380 + 381 + // Build the record (required by lexicon) 382 + record := map[string]interface{}{ 383 + "$type": "social.coves.community.post", 384 + "community": communityRef.DID, 385 + "author": authorView.DID, 386 + "createdAt": postView.CreatedAt.Format(time.RFC3339), 387 + } 388 + 389 + // Add optional fields to record if present 390 + if title.Valid { 391 + record["title"] = title.String 392 + } 393 + if content.Valid { 394 + record["content"] = content.String 395 + } 396 + // Reuse already-parsed facets and embed from PostView to avoid double parsing 397 + if facets.Valid { 398 + record["facets"] = postView.TextFacets 399 + } 400 + if embed.Valid { 401 + record["embed"] = postView.Embed 402 + } 403 + if labelsJSON.Valid { 404 + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 405 + var selfLabels posts.SelfLabels 406 + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err != nil { 407 + return nil, fmt.Errorf("failed to parse labels JSON for post %s: %w", postView.URI, err) 408 + } 409 + record["labels"] = selfLabels 410 + } 411 + 412 + postView.Record = record 413 + 414 + return &postView, nil 415 + }
+244
internal/db/postgres/post_repo_cursor_test.go
··· 1 + package postgres 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/base64" 7 + "testing" 8 + "time" 9 + 10 + "Coves/internal/core/posts" 11 + ) 12 + 13 + func TestParseAuthorPostsCursor(t *testing.T) { 14 + repo := &postgresPostRepo{db: nil} // db not needed for cursor parsing 15 + 16 + // Helper to create a valid cursor 17 + makeCursor := func(timestamp, uri string) string { 18 + return base64.URLEncoding.EncodeToString([]byte(timestamp + "|" + uri)) 19 + } 20 + 21 + validTimestamp := time.Now().Format(time.RFC3339Nano) 22 + validURI := "at://did:plc:test123/social.coves.community.post/abc123" 23 + 24 + tests := []struct { 25 + name string 26 + cursor *string 27 + wantFilter bool 28 + wantErr bool 29 + errMsg string 30 + }{ 31 + { 32 + name: "nil cursor returns empty filter", 33 + cursor: nil, 34 + wantFilter: false, 35 + wantErr: false, 36 + }, 37 + { 38 + name: "empty cursor returns empty filter", 39 + cursor: strPtr(""), 40 + wantFilter: false, 41 + wantErr: false, 42 + }, 43 + { 44 + name: "valid cursor", 45 + cursor: strPtr(makeCursor(validTimestamp, validURI)), 46 + wantFilter: true, 47 + wantErr: false, 48 + }, 49 + { 50 + name: "cursor too long", 51 + cursor: strPtr(makeCursor(validTimestamp, string(make([]byte, 600)))), 52 + wantFilter: false, 53 + wantErr: true, 54 + errMsg: "exceeds maximum length", 55 + }, 56 + { 57 + name: "invalid base64", 58 + cursor: strPtr("not-valid-base64!!!"), 59 + wantFilter: false, 60 + wantErr: true, 61 + errMsg: "invalid base64", 62 + }, 63 + { 64 + name: "missing pipe delimiter", 65 + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte("no-pipe-here"))), 66 + wantFilter: false, 67 + wantErr: true, 68 + errMsg: "malformed cursor format", 69 + }, 70 + { 71 + name: "invalid timestamp", 72 + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte("not-a-timestamp|" + validURI))), 73 + wantFilter: false, 74 + wantErr: true, 75 + errMsg: "invalid timestamp", 76 + }, 77 + { 78 + name: "invalid URI format", 79 + cursor: strPtr(base64.URLEncoding.EncodeToString([]byte(validTimestamp + "|not-an-at-uri"))), 80 + wantFilter: false, 81 + wantErr: true, 82 + errMsg: "invalid URI format", 83 + }, 84 + } 85 + 86 + for _, tt := range tests { 87 + t.Run(tt.name, func(t *testing.T) { 88 + filter, args, err := repo.parseAuthorPostsCursor(tt.cursor, 1) 89 + 90 + if tt.wantErr { 91 + if err == nil { 92 + t.Errorf("parseAuthorPostsCursor() = nil error, want error containing %q", tt.errMsg) 93 + } else if !posts.IsValidationError(err) && err != posts.ErrInvalidCursor { 94 + // Check if error wraps ErrInvalidCursor 95 + if tt.errMsg != "" && !containsStr(err.Error(), tt.errMsg) { 96 + t.Errorf("parseAuthorPostsCursor() error = %v, want error containing %q", err, tt.errMsg) 97 + } 98 + } 99 + } else { 100 + if err != nil { 101 + t.Errorf("parseAuthorPostsCursor() = %v, want nil error", err) 102 + } 103 + } 104 + 105 + if tt.wantFilter { 106 + if filter == "" { 107 + t.Error("parseAuthorPostsCursor() filter = empty, want non-empty filter") 108 + } 109 + if len(args) == 0 { 110 + t.Error("parseAuthorPostsCursor() args = empty, want non-empty args") 111 + } 112 + } else if !tt.wantErr { 113 + if filter != "" { 114 + t.Errorf("parseAuthorPostsCursor() filter = %q, want empty", filter) 115 + } 116 + } 117 + }) 118 + } 119 + } 120 + 121 + func TestBuildAuthorPostsCursor(t *testing.T) { 122 + repo := &postgresPostRepo{db: nil} 123 + 124 + now := time.Now() 125 + post := &posts.PostView{ 126 + URI: "at://did:plc:test123/social.coves.community.post/abc123", 127 + CreatedAt: now, 128 + } 129 + 130 + cursor := repo.buildAuthorPostsCursor(post) 131 + 132 + // Decode and verify cursor 133 + decoded, err := base64.URLEncoding.DecodeString(cursor) 134 + if err != nil { 135 + t.Fatalf("Failed to decode cursor: %v", err) 136 + } 137 + 138 + // Should contain timestamp|uri 139 + decodedStr := string(decoded) 140 + if !containsStr(decodedStr, "|") { 141 + t.Errorf("Cursor should contain '|' delimiter, got %q", decodedStr) 142 + } 143 + if !containsStr(decodedStr, post.URI) { 144 + t.Errorf("Cursor should contain URI, got %q", decodedStr) 145 + } 146 + if !containsStr(decodedStr, now.Format(time.RFC3339Nano)) { 147 + t.Errorf("Cursor should contain timestamp, got %q", decodedStr) 148 + } 149 + } 150 + 151 + func TestBuildAndParseCursorRoundTrip(t *testing.T) { 152 + repo := &postgresPostRepo{db: nil} 153 + 154 + now := time.Now() 155 + post := &posts.PostView{ 156 + URI: "at://did:plc:test123/social.coves.community.post/abc123", 157 + CreatedAt: now, 158 + } 159 + 160 + // Build cursor 161 + cursor := repo.buildAuthorPostsCursor(post) 162 + 163 + // Parse it back 164 + filter, args, err := repo.parseAuthorPostsCursor(&cursor, 1) 165 + 166 + if err != nil { 167 + t.Fatalf("Failed to parse cursor: %v", err) 168 + } 169 + 170 + if filter == "" { 171 + t.Error("Expected non-empty filter") 172 + } 173 + 174 + if len(args) != 2 { 175 + t.Errorf("Expected 2 args, got %d", len(args)) 176 + } 177 + 178 + // First arg should be timestamp string 179 + if ts, ok := args[0].(string); ok { 180 + parsedTime, err := time.Parse(time.RFC3339Nano, ts) 181 + if err != nil { 182 + t.Errorf("First arg is not a valid timestamp: %v", err) 183 + } 184 + if !parsedTime.Equal(now) { 185 + t.Errorf("Timestamp mismatch: got %v, want %v", parsedTime, now) 186 + } 187 + } else { 188 + t.Errorf("First arg should be string, got %T", args[0]) 189 + } 190 + 191 + // Second arg should be URI 192 + if uri, ok := args[1].(string); ok { 193 + if uri != post.URI { 194 + t.Errorf("URI mismatch: got %q, want %q", uri, post.URI) 195 + } 196 + } else { 197 + t.Errorf("Second arg should be string, got %T", args[1]) 198 + } 199 + } 200 + 201 + // Helper functions 202 + func strPtr(s string) *string { 203 + return &s 204 + } 205 + 206 + func containsStr(s, substr string) bool { 207 + for i := 0; i <= len(s)-len(substr); i++ { 208 + if s[i:i+len(substr)] == substr { 209 + return true 210 + } 211 + } 212 + return false 213 + } 214 + 215 + // Ensure the mock repository satisfies the interface 216 + var _ posts.Repository = (*mockPostRepository)(nil) 217 + 218 + type mockPostRepository struct { 219 + db *sql.DB 220 + } 221 + 222 + func (m *mockPostRepository) Create(ctx context.Context, post *posts.Post) error { 223 + return nil 224 + } 225 + 226 + func (m *mockPostRepository) GetByURI(ctx context.Context, uri string) (*posts.Post, error) { 227 + return nil, nil 228 + } 229 + 230 + func (m *mockPostRepository) GetByAuthor(ctx context.Context, req posts.GetAuthorPostsRequest) ([]*posts.PostView, *string, error) { 231 + return nil, nil, nil 232 + } 233 + 234 + func (m *mockPostRepository) SoftDelete(ctx context.Context, uri string) error { 235 + return nil 236 + } 237 + 238 + func (m *mockPostRepository) Update(ctx context.Context, post *posts.Post) error { 239 + return nil 240 + } 241 + 242 + func (m *mockPostRepository) UpdateVoteCounts(ctx context.Context, uri string, upvotes, downvotes int) error { 243 + return nil 244 + }
+2
CLAUDE.md
··· 56 56 - [ ] ย **Does the Lexicon make sense?**ย (Would it work for other forums?) 57 57 - [ ] ย **AppView only indexes**: We don't write to CAR files, only read from firehose 58 58 59 + Always prefer error codes over dataintegrity boolean markers 60 + 59 61 ## Security-First Building 60 62 61 63 ### Every Feature MUST:
+47 -1
Makefile
··· 1 - .PHONY: help dev-up dev-down dev-logs dev-status dev-reset test e2e-test clean verify-stack create-test-account mobile-full-setup 1 + .PHONY: help dev-up dev-down dev-logs dev-status dev-reset test test-all e2e-test clean verify-stack create-test-account mobile-full-setup 2 2 3 3 # Default target - show help 4 4 .DEFAULT_GOAL := help ··· 8 8 RESET := \033[0m 9 9 GREEN := \033[32m 10 10 YELLOW := \033[33m 11 + RED := \033[31m 11 12 12 13 # Load test database configuration from .env.dev 13 14 include .env.dev ··· 156 157 @docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test stop postgres-test 157 158 @echo "$(GREEN)โœ“ Test database stopped$(RESET)" 158 159 160 + test-all: ## Run ALL tests with live infrastructure (required before merge) 161 + @echo "" 162 + @echo "$(CYAN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)" 163 + @echo "$(CYAN) FULL TEST SUITE - All tests with live infrastructure $(RESET)" 164 + @echo "$(CYAN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)" 165 + @echo "" 166 + @echo "$(YELLOW)โ–ถ Checking infrastructure...$(RESET)" 167 + @echo "" 168 + @# Check dev stack is running 169 + @echo " Checking dev stack (PDS, Jetstream, PLC)..." 170 + @docker-compose -f docker-compose.dev.yml --env-file .env.dev ps 2>/dev/null | grep -q "Up" || \ 171 + (echo "$(RED) โœ— Dev stack not running. Run 'make dev-up' first.$(RESET)" && exit 1) 172 + @echo " $(GREEN)โœ“ Dev stack is running$(RESET)" 173 + @# Check AppView is running 174 + @echo " Checking AppView (port 8081)..." 175 + @curl -sf http://127.0.0.1:8081/xrpc/_health >/dev/null 2>&1 || \ 176 + curl -sf http://127.0.0.1:8081/ >/dev/null 2>&1 || \ 177 + (echo "$(RED) โœ— AppView not running. Run 'make run' in another terminal.$(RESET)" && exit 1) 178 + @echo " $(GREEN)โœ“ AppView is running$(RESET)" 179 + @# Check test database 180 + @echo " Checking test database (port 5434)..." 181 + @docker-compose -f docker-compose.dev.yml --env-file .env.dev ps postgres-test 2>/dev/null | grep -q "Up" || \ 182 + (echo "$(YELLOW) โš  Test database not running, starting it...$(RESET)" && \ 183 + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile test up -d postgres-test && \ 184 + sleep 3 && \ 185 + goose -dir internal/db/migrations postgres "postgresql://$(POSTGRES_TEST_USER):$(POSTGRES_TEST_PASSWORD)@localhost:$(POSTGRES_TEST_PORT)/$(POSTGRES_TEST_DB)?sslmode=disable" up) 186 + @echo " $(GREEN)โœ“ Test database is running$(RESET)" 187 + @echo "" 188 + @echo "$(GREEN)โ–ถ [1/3] Unit & Package Tests (./cmd/... ./internal/...)$(RESET)" 189 + @echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)" 190 + @LOG_ENABLED=false go test ./cmd/... ./internal/... -timeout 120s 191 + @echo "" 192 + @echo "$(GREEN)โ–ถ [2/3] Integration Tests (./tests/integration/...)$(RESET)" 193 + @echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)" 194 + @LOG_ENABLED=false go test ./tests/integration/... -timeout 180s 195 + @echo "" 196 + @echo "$(GREEN)โ–ถ [3/3] E2E Tests (./tests/e2e/...)$(RESET)" 197 + @echo "$(CYAN)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€$(RESET)" 198 + @LOG_ENABLED=false go test ./tests/e2e/... -timeout 180s 199 + @echo "" 200 + @echo "$(GREEN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)" 201 + @echo "$(GREEN) โœ“ ALL TESTS PASSED - Safe to merge $(RESET)" 202 + @echo "$(GREEN)โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•$(RESET)" 203 + @echo "" 204 + 159 205 ##@ Code Quality 160 206 161 207 fmt: ## Format all Go code with gofmt
+13
tests/lexicon_validation_test.go
··· 1 1 package tests 2 2 3 3 import ( 4 + "io" 5 + "log" 4 6 "os" 5 7 "path/filepath" 6 8 "strings" ··· 9 11 lexicon "github.com/bluesky-social/indigo/atproto/lexicon" 10 12 ) 11 13 14 + // TestMain controls test setup for the tests package. 15 + // Set LOG_ENABLED=false to suppress application log output during tests. 16 + func TestMain(m *testing.M) { 17 + // Silence logs when LOG_ENABLED=false (used by make test-all) 18 + if os.Getenv("LOG_ENABLED") == "false" { 19 + log.SetOutput(io.Discard) 20 + } 21 + 22 + os.Exit(m.Run()) 23 + } 24 + 12 25 func TestLexiconSchemaValidation(t *testing.T) { 13 26 // Create a new catalog 14 27 catalog := lexicon.NewBaseCatalog()
+14
tests/unit/community_service_test.go
··· 4 4 "Coves/internal/core/communities" 5 5 "context" 6 6 "fmt" 7 + "io" 8 + "log" 7 9 "net/http" 8 10 "net/http/httptest" 11 + "os" 9 12 "strings" 10 13 "sync/atomic" 11 14 "testing" 12 15 "time" 13 16 ) 14 17 18 + // TestMain controls test setup for the unit package. 19 + // Set LOG_ENABLED=false to suppress application log output during tests. 20 + func TestMain(m *testing.M) { 21 + // Silence logs when LOG_ENABLED=false (used by make test-all) 22 + if os.Getenv("LOG_ENABLED") == "false" { 23 + log.SetOutput(io.Discard) 24 + } 25 + 26 + os.Exit(m.Run()) 27 + } 28 + 15 29 // mockCommunityRepo is a minimal mock for testing service layer 16 30 type mockCommunityRepo struct { 17 31 communities map[string]*communities.Community
+82 -17
tests/e2e/error_recovery_test.go
··· 86 86 t.Run("Events processed successfully after connection", func(t *testing.T) { 87 87 // Even though we can't easily test WebSocket reconnection in unit tests, 88 88 // we can verify that events are processed correctly after establishing connection 89 - consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 90 89 ctx := context.Background() 91 90 91 + // Pre-create user - identity events only update existing users 92 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 93 + DID: "did:plc:reconnect123", 94 + Handle: "reconnect.old.test", 95 + PDSURL: "http://localhost:3001", 96 + }) 97 + if err != nil { 98 + t.Fatalf("Failed to create test user: %v", err) 99 + } 100 + 101 + consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 102 + 103 + // Send identity event with new handle 92 104 event := jetstream.JetstreamEvent{ 93 105 Did: "did:plc:reconnect123", 94 106 Kind: "identity", ··· 100 112 }, 101 113 } 102 114 103 - err := consumer.HandleIdentityEventPublic(ctx, &event) 115 + err = consumer.HandleIdentityEventPublic(ctx, &event) 104 116 if err != nil { 105 117 t.Fatalf("Failed to process event: %v", err) 106 118 } ··· 217 229 218 230 // Verify consumer can still process valid events after malformed ones 219 231 t.Run("Valid event after malformed events", func(t *testing.T) { 220 - consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 221 232 ctx := context.Background() 222 233 234 + // Pre-create user - identity events only update existing users 235 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 236 + DID: "did:plc:recovery123", 237 + Handle: "recovery.old.test", 238 + PDSURL: "http://localhost:3001", 239 + }) 240 + if err != nil { 241 + t.Fatalf("Failed to create test user: %v", err) 242 + } 243 + 244 + consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 245 + 246 + // Send valid identity event with new handle 223 247 validEvent := jetstream.JetstreamEvent{ 224 248 Did: "did:plc:recovery123", 225 249 Kind: "identity", ··· 231 255 }, 232 256 } 233 257 234 - err := consumer.HandleIdentityEventPublic(ctx, &validEvent) 258 + err = consumer.HandleIdentityEventPublic(ctx, &validEvent) 235 259 if err != nil { 236 260 t.Fatalf("Failed to process valid event after malformed events: %v", err) 237 261 } 238 262 239 - // Verify user was indexed 263 + // Verify user handle was updated 240 264 user, err := userService.GetUserByDID(ctx, "did:plc:recovery123") 241 265 if err != nil { 242 - t.Fatalf("User not indexed after malformed events: %v", err) 266 + t.Fatalf("User not found after valid event: %v", err) 243 267 } 244 268 245 269 if user.Handle != "recovery.test" { ··· 362 386 ctx := context.Background() 363 387 364 388 t.Run("Indexing continues during PDS unavailability", func(t *testing.T) { 365 - // Even though PDS is "unavailable", we can still index events from Jetstream 389 + // Even though PDS is "unavailable", we can still update events from Jetstream 366 390 // because we don't need to contact PDS for identity events 391 + 392 + // Pre-create user - identity events only update existing users 393 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 394 + DID: "did:plc:pdsfail123", 395 + Handle: "pdsfail.old.test", 396 + PDSURL: mockPDS.URL, 397 + }) 398 + if err != nil { 399 + t.Fatalf("Failed to create test user: %v", err) 400 + } 401 + 367 402 consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 368 403 369 404 event := jetstream.JetstreamEvent{ ··· 377 412 }, 378 413 } 379 414 380 - err := consumer.HandleIdentityEventPublic(ctx, &event) 415 + err = consumer.HandleIdentityEventPublic(ctx, &event) 381 416 if err != nil { 382 - t.Fatalf("Failed to index event during PDS unavailability: %v", err) 417 + t.Fatalf("Failed to process event during PDS unavailability: %v", err) 383 418 } 384 419 385 - // Verify user was indexed 420 + // Verify user handle was updated 386 421 user, err := userService.GetUserByDID(ctx, "did:plc:pdsfail123") 387 422 if err != nil { 388 423 t.Fatalf("Failed to get user during PDS unavailability: %v", err) ··· 392 427 t.Errorf("Expected handle pdsfail.test, got %s", user.Handle) 393 428 } 394 429 395 - t.Log("โœ“ Indexing continues successfully even when PDS is unavailable") 430 + t.Log("โœ“ Handle updates continue successfully even when PDS is unavailable") 396 431 }) 397 432 398 433 t.Run("System recovers when PDS comes back online", func(t *testing.T) { 399 434 // Mark PDS as available again 400 435 shouldFail.Store(false) 401 436 437 + // Pre-create user - identity events only update existing users 438 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 439 + DID: "did:plc:pdsrecovery123", 440 + Handle: "pdsrecovery.old.test", 441 + PDSURL: mockPDS.URL, 442 + }) 443 + if err != nil { 444 + t.Fatalf("Failed to create test user: %v", err) 445 + } 446 + 402 447 // Now operations that require PDS should work 403 448 consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 404 449 ··· 413 458 }, 414 459 } 415 460 416 - err := consumer.HandleIdentityEventPublic(ctx, &event) 461 + err = consumer.HandleIdentityEventPublic(ctx, &event) 417 462 if err != nil { 418 - t.Fatalf("Failed to index event after PDS recovery: %v", err) 463 + t.Fatalf("Failed to process event after PDS recovery: %v", err) 419 464 } 420 465 421 466 user, err := userService.GetUserByDID(ctx, "did:plc:pdsrecovery123") ··· 449 494 t.Run("Handle updates arriving out of order", func(t *testing.T) { 450 495 did := "did:plc:outoforder123" 451 496 497 + // Pre-create user - identity events only update existing users 498 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 499 + DID: did, 500 + Handle: "initial.handle", 501 + PDSURL: "http://localhost:3001", 502 + }) 503 + if err != nil { 504 + t.Fatalf("Failed to create test user: %v", err) 505 + } 506 + 452 507 // Event 3: Latest handle 453 508 event3 := jetstream.JetstreamEvent{ 454 509 Did: did, ··· 511 566 }) 512 567 513 568 t.Run("Duplicate events at different times", func(t *testing.T) { 514 - did := "did:plc:duplicate123" 569 + did := "did:plc:dupevents123" 570 + 571 + // Pre-create user - identity events only update existing users 572 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 573 + DID: did, 574 + Handle: "duplicate.handle", 575 + PDSURL: "http://localhost:3001", 576 + }) 577 + if err != nil { 578 + t.Fatalf("Failed to create test user: %v", err) 579 + } 515 580 516 - // Create user 581 + // Send identity event 517 582 event1 := jetstream.JetstreamEvent{ 518 583 Did: did, 519 584 Kind: "identity", ··· 525 590 }, 526 591 } 527 592 528 - err := consumer.HandleIdentityEventPublic(ctx, &event1) 593 + err = consumer.HandleIdentityEventPublic(ctx, &event1) 529 594 if err != nil { 530 595 t.Fatalf("Failed to process first event: %v", err) 531 596 } ··· 536 601 t.Fatalf("Failed to process duplicate event: %v", err) 537 602 } 538 603 539 - // Verify still only one user 604 + // Verify still only one user with same handle 540 605 user, err := userService.GetUserByDID(ctx, did) 541 606 if err != nil { 542 607 t.Fatalf("Failed to get user: %v", err)
+43 -35
tests/integration/jetstream_consumer_test.go
··· 25 25 26 26 ctx := context.Background() 27 27 28 - t.Run("Index new user from identity event", func(t *testing.T) { 29 - // Simulate an identity event from Jetstream 28 + t.Run("Skip identity event for non-existent user", func(t *testing.T) { 29 + // Identity events for users not in our database should be silently skipped 30 + // Users are only indexed during OAuth login/signup, not from Jetstream events 30 31 event := jetstream.JetstreamEvent{ 31 - Did: "did:plc:jetstream123", 32 + Did: "did:plc:nonexistent123", 32 33 Kind: "identity", 33 34 Identity: &jetstream.IdentityEvent{ 34 - Did: "did:plc:jetstream123", 35 - Handle: "alice.jetstream.test", 35 + Did: "did:plc:nonexistent123", 36 + Handle: "nonexistent.jetstream.test", 36 37 Seq: 12345, 37 38 Time: time.Now().Format(time.RFC3339), 38 39 }, ··· 40 41 41 42 consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 42 43 43 - // Handle the event 44 + // Handle the event - should return nil (skip silently, not error) 44 45 err := consumer.HandleIdentityEventPublic(ctx, &event) 45 46 if err != nil { 46 - t.Fatalf("failed to handle identity event: %v", err) 47 + t.Fatalf("expected nil error for non-existent user, got: %v", err) 47 48 } 48 49 49 - // Verify user was indexed 50 - user, err := userService.GetUserByDID(ctx, "did:plc:jetstream123") 51 - if err != nil { 52 - t.Fatalf("failed to get indexed user: %v", err) 53 - } 54 - 55 - if user.DID != "did:plc:jetstream123" { 56 - t.Errorf("expected DID did:plc:jetstream123, got %s", user.DID) 57 - } 58 - 59 - if user.Handle != "alice.jetstream.test" { 60 - t.Errorf("expected handle alice.jetstream.test, got %s", user.Handle) 50 + // Verify user was NOT created 51 + _, err = userService.GetUserByDID(ctx, "did:plc:nonexistent123") 52 + if err == nil { 53 + t.Fatal("expected user to NOT be created, but found in database") 61 54 } 62 55 }) 63 56 ··· 103 96 } 104 97 }) 105 98 106 - t.Run("Index multiple users", func(t *testing.T) { 107 - consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 108 - 109 - users := []struct { 110 - did string 111 - handle string 99 + t.Run("Update multiple existing users via identity events", func(t *testing.T) { 100 + // Pre-create users - identity events only update existing users 101 + testUsers := []struct { 102 + did string 103 + oldHandle string 104 + newHandle string 112 105 }{ 113 - {"did:plc:multi1", "user1.test"}, 114 - {"did:plc:multi2", "user2.test"}, 115 - {"did:plc:multi3", "user3.test"}, 106 + {"did:plc:multi1", "user1.old.test", "user1.new.test"}, 107 + {"did:plc:multi2", "user2.old.test", "user2.new.test"}, 108 + {"did:plc:multi3", "user3.old.test", "user3.new.test"}, 109 + } 110 + 111 + // Create users first 112 + for _, u := range testUsers { 113 + _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 114 + DID: u.did, 115 + Handle: u.oldHandle, 116 + PDSURL: "https://bsky.social", 117 + }) 118 + if err != nil { 119 + t.Fatalf("failed to create user %s: %v", u.oldHandle, err) 120 + } 116 121 } 117 122 118 - for _, u := range users { 123 + consumer := jetstream.NewUserEventConsumer(userService, resolver, "", "") 124 + 125 + // Send identity events with new handles 126 + for _, u := range testUsers { 119 127 event := jetstream.JetstreamEvent{ 120 128 Did: u.did, 121 129 Kind: "identity", 122 130 Identity: &jetstream.IdentityEvent{ 123 131 Did: u.did, 124 - Handle: u.handle, 132 + Handle: u.newHandle, 125 133 Seq: 12345, 126 134 Time: time.Now().Format(time.RFC3339), 127 135 }, ··· 129 137 130 138 err := consumer.HandleIdentityEventPublic(ctx, &event) 131 139 if err != nil { 132 - t.Fatalf("failed to index user %s: %v", u.handle, err) 140 + t.Fatalf("failed to handle identity event for %s: %v", u.newHandle, err) 133 141 } 134 142 } 135 143 136 - // Verify all users indexed 137 - for _, u := range users { 144 + // Verify all users have updated handles 145 + for _, u := range testUsers { 138 146 user, err := userService.GetUserByDID(ctx, u.did) 139 147 if err != nil { 140 148 t.Fatalf("user %s not found: %v", u.did, err) 141 149 } 142 150 143 - if user.Handle != u.handle { 144 - t.Errorf("expected handle %s, got %s", u.handle, user.Handle) 151 + if user.Handle != u.newHandle { 152 + t.Errorf("expected handle %s, got %s", u.newHandle, user.Handle) 145 153 } 146 154 } 147 155 })
+42 -21
internal/api/routes/user.go
··· 6 6 "errors" 7 7 "log" 8 8 "net/http" 9 - "time" 9 + "strings" 10 10 11 11 "github.com/go-chi/chi/v5" 12 12 ) ··· 37 37 38 38 // GetProfile handles social.coves.actor.getprofile 39 39 // Query endpoint that retrieves a user profile by DID or handle 40 + // Returns profileViewDetailed with stats per lexicon specification 40 41 func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) { 41 42 ctx := r.Context() 42 43 43 44 // Get actor parameter (DID or handle) 44 45 actor := r.URL.Query().Get("actor") 45 46 if actor == "" { 46 - http.Error(w, "actor parameter is required", http.StatusBadRequest) 47 + writeXRPCError(w, "InvalidRequest", "actor parameter is required", http.StatusBadRequest) 47 48 return 48 49 } 49 50 50 - var user *users.User 51 - var err error 52 - 53 - // Determine if actor is a DID or handle 54 - // DIDs start with "did:", handles don't 55 - if len(actor) > 4 && actor[:4] == "did:" { 56 - user, err = h.userService.GetUserByDID(ctx, actor) 51 + // Resolve actor to DID 52 + var did string 53 + if strings.HasPrefix(actor, "did:") { 54 + did = actor 57 55 } else { 58 - user, err = h.userService.GetUserByHandle(ctx, actor) 56 + // Resolve handle to DID 57 + resolvedDID, err := h.userService.ResolveHandleToDID(ctx, actor) 58 + if err != nil { 59 + writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound) 60 + return 61 + } 62 + did = resolvedDID 59 63 } 60 64 65 + // Get full profile with stats 66 + profile, err := h.userService.GetProfile(ctx, did) 61 67 if err != nil { 62 - http.Error(w, "user not found", http.StatusNotFound) 68 + if errors.Is(err, users.ErrUserNotFound) { 69 + writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound) 70 + return 71 + } 72 + log.Printf("Failed to get profile for %s: %v", did, err) 73 + writeXRPCError(w, "InternalError", "failed to get profile", http.StatusInternalServerError) 63 74 return 64 75 } 65 76 66 - // Minimal profile response (matching lexicon structure) 67 - response := map[string]interface{}{ 68 - "did": user.DID, 69 - "profile": map[string]interface{}{ 70 - "handle": user.Handle, 71 - "createdAt": user.CreatedAt.Format(time.RFC3339), 72 - }, 77 + // Marshal to bytes first to avoid partial writes on encoding errors 78 + responseBytes, err := json.Marshal(profile) 79 + if err != nil { 80 + log.Printf("Failed to marshal profile response: %v", err) 81 + writeXRPCError(w, "InternalError", "failed to encode response", http.StatusInternalServerError) 82 + return 73 83 } 74 84 75 85 w.Header().Set("Content-Type", "application/json") 76 - w.WriteHeader(http.StatusOK) 77 - if err := json.NewEncoder(w).Encode(response); err != nil { 78 - log.Printf("Failed to encode response: %v", err) 86 + if _, err := w.Write(responseBytes); err != nil { 87 + log.Printf("Failed to write response: %v", err) 88 + } 89 + } 90 + 91 + // writeXRPCError writes a standardized XRPC error response 92 + func writeXRPCError(w http.ResponseWriter, errorName, message string, statusCode int) { 93 + w.Header().Set("Content-Type", "application/json") 94 + w.WriteHeader(statusCode) 95 + if err := json.NewEncoder(w).Encode(map[string]interface{}{ 96 + "error": errorName, 97 + "message": message, 98 + }); err != nil { 99 + log.Printf("Failed to encode error response: %v", err) 79 100 } 80 101 } 81 102
+33 -1
internal/core/users/service.go
··· 147 147 if err == nil && user != nil { 148 148 return user.DID, nil 149 149 } 150 - // If not found locally, fall through to external resolution 150 + // Log database errors (but not "not found" which is expected for unindexed users) 151 + if err != nil && !errors.Is(err, ErrUserNotFound) { 152 + log.Printf("Warning: database error during handle lookup for %s (falling back to external resolution): %v", handle, err) 153 + } 154 + // If not found locally or error, fall through to external resolution 151 155 152 156 // Slow path: use identity resolver for external DNS/HTTPS resolution 153 157 did, _, err := s.identityResolver.ResolveHandle(ctx, handle) ··· 259 263 return nil 260 264 } 261 265 266 + // GetProfile retrieves a user's full profile with aggregated statistics. 267 + // Returns a ProfileViewDetailed matching the social.coves.actor.defs#profileViewDetailed lexicon. 268 + func (s *userService) GetProfile(ctx context.Context, did string) (*ProfileViewDetailed, error) { 269 + did = strings.TrimSpace(did) 270 + if did == "" { 271 + return nil, fmt.Errorf("DID is required") 272 + } 273 + 274 + // Get the user first 275 + user, err := s.userRepo.GetByDID(ctx, did) 276 + if err != nil { 277 + return nil, fmt.Errorf("failed to get user: %w", err) 278 + } 279 + 280 + // Get aggregated stats 281 + stats, err := s.userRepo.GetProfileStats(ctx, did) 282 + if err != nil { 283 + return nil, fmt.Errorf("failed to get profile stats: %w", err) 284 + } 285 + 286 + return &ProfileViewDetailed{ 287 + DID: user.DID, 288 + Handle: user.Handle, 289 + CreatedAt: user.CreatedAt, 290 + Stats: stats, 291 + }, nil 292 + } 293 + 262 294 func (s *userService) validateCreateRequest(req CreateUserRequest) error { 263 295 if strings.TrimSpace(req.DID) == "" { 264 296 return fmt.Errorf("DID is required")
+22
internal/core/users/user.go
··· 38 38 RefreshJwt string `json:"refreshJwt"` 39 39 PDSURL string `json:"pdsUrl"` 40 40 } 41 + 42 + // ProfileStats contains aggregated user statistics 43 + // Matches the social.coves.actor.defs#profileStats lexicon 44 + type ProfileStats struct { 45 + PostCount int `json:"postCount"` 46 + CommentCount int `json:"commentCount"` 47 + CommunityCount int `json:"communityCount"` // Number of communities subscribed to 48 + Reputation int `json:"reputation"` // Global reputation score (sum across communities) 49 + MembershipCount int `json:"membershipCount"` // Number of communities with active membership 50 + } 51 + 52 + // ProfileViewDetailed is the full profile response 53 + // Matches the social.coves.actor.defs#profileViewDetailed lexicon 54 + type ProfileViewDetailed struct { 55 + DID string `json:"did"` 56 + Handle string `json:"handle,omitempty"` 57 + CreatedAt time.Time `json:"createdAt"` 58 + Stats *ProfileStats `json:"stats,omitempty"` 59 + // Future fields (require additional infrastructure): 60 + // DisplayName, Bio, Avatar, Banner (from PDS profile record) 61 + // Viewer (requires user-to-user blocking infrastructure) 62 + }
+265
internal/api/handlers/actor/get_comments.go
··· 1 + package actor 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "log" 7 + "net/http" 8 + "strconv" 9 + "strings" 10 + 11 + "Coves/internal/api/middleware" 12 + "Coves/internal/core/comments" 13 + "Coves/internal/core/users" 14 + "Coves/internal/core/votes" 15 + ) 16 + 17 + // GetCommentsHandler handles actor comment retrieval 18 + type GetCommentsHandler struct { 19 + commentService comments.Service 20 + userService users.UserService 21 + voteService votes.Service 22 + } 23 + 24 + // NewGetCommentsHandler creates a new actor comments handler 25 + func NewGetCommentsHandler( 26 + commentService comments.Service, 27 + userService users.UserService, 28 + voteService votes.Service, 29 + ) *GetCommentsHandler { 30 + return &GetCommentsHandler{ 31 + commentService: commentService, 32 + userService: userService, 33 + voteService: voteService, 34 + } 35 + } 36 + 37 + // HandleGetComments retrieves comments by an actor (user) 38 + // GET /xrpc/social.coves.actor.getComments?actor={did_or_handle}&community=...&limit=50&cursor=... 39 + func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) { 40 + if r.Method != http.MethodGet { 41 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 42 + return 43 + } 44 + 45 + // Parse query parameters 46 + req, err := h.parseRequest(r) 47 + if err != nil { 48 + // Check if it's an actor not found error (from handle resolution) 49 + var actorNotFound *actorNotFoundError 50 + if errors.As(err, &actorNotFound) { 51 + writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 52 + return 53 + } 54 + 55 + // Check if it's an infrastructure failure during resolution 56 + // (database down, DNS failures, network errors, etc.) 57 + var resolutionFailed *resolutionFailedError 58 + if errors.As(err, &resolutionFailed) { 59 + log.Printf("ERROR: Actor resolution infrastructure failure: %v", err) 60 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to resolve actor identity") 61 + return 62 + } 63 + 64 + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 65 + return 66 + } 67 + 68 + // Get viewer DID for populating viewer state (optional) 69 + viewerDID := middleware.GetUserDID(r) 70 + if viewerDID != "" { 71 + req.ViewerDID = &viewerDID 72 + } 73 + 74 + // Get actor comments from service 75 + response, err := h.commentService.GetActorComments(r.Context(), req) 76 + if err != nil { 77 + handleCommentServiceError(w, err) 78 + return 79 + } 80 + 81 + // Populate viewer vote state if authenticated 82 + h.populateViewerVoteState(r, response) 83 + 84 + // Pre-encode response to buffer before writing headers 85 + // This ensures we can return a proper error if encoding fails 86 + responseBytes, err := json.Marshal(response) 87 + if err != nil { 88 + log.Printf("ERROR: Failed to encode actor comments response: %v", err) 89 + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 90 + return 91 + } 92 + 93 + // Return comments 94 + w.Header().Set("Content-Type", "application/json") 95 + w.WriteHeader(http.StatusOK) 96 + if _, err := w.Write(responseBytes); err != nil { 97 + log.Printf("ERROR: Failed to write actor comments response: %v", err) 98 + } 99 + } 100 + 101 + // parseRequest parses query parameters into GetActorCommentsRequest 102 + func (h *GetCommentsHandler) parseRequest(r *http.Request) (*comments.GetActorCommentsRequest, error) { 103 + req := &comments.GetActorCommentsRequest{} 104 + 105 + // Required: actor (handle or DID) 106 + actor := r.URL.Query().Get("actor") 107 + if actor == "" { 108 + return nil, &validationError{field: "actor", message: "actor parameter is required"} 109 + } 110 + // Validate actor length to prevent DoS via massive strings 111 + // Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer) 112 + // Max handle length is 253 chars (DNS limit) 113 + const maxActorLength = 2048 114 + if len(actor) > maxActorLength { 115 + return nil, &validationError{field: "actor", message: "actor parameter exceeds maximum length"} 116 + } 117 + 118 + // Resolve actor to DID if it's a handle 119 + actorDID, err := h.resolveActor(r, actor) 120 + if err != nil { 121 + return nil, err 122 + } 123 + req.ActorDID = actorDID 124 + 125 + // Optional: community (handle or DID) 126 + req.Community = r.URL.Query().Get("community") 127 + 128 + // Optional: limit (default: 50, max: 100) 129 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 130 + limit, err := strconv.Atoi(limitStr) 131 + if err != nil { 132 + return nil, &validationError{field: "limit", message: "limit must be a valid integer"} 133 + } 134 + req.Limit = limit 135 + } 136 + 137 + // Optional: cursor 138 + if cursor := r.URL.Query().Get("cursor"); cursor != "" { 139 + req.Cursor = &cursor 140 + } 141 + 142 + return req, nil 143 + } 144 + 145 + // resolveActor converts an actor identifier (handle or DID) to a DID 146 + func (h *GetCommentsHandler) resolveActor(r *http.Request, actor string) (string, error) { 147 + // If it's already a DID, return it 148 + if strings.HasPrefix(actor, "did:") { 149 + return actor, nil 150 + } 151 + 152 + // It's a handle - resolve to DID using user service 153 + did, err := h.userService.ResolveHandleToDID(r.Context(), actor) 154 + if err != nil { 155 + // Check for context errors (timeouts, cancellation) - these are infrastructure errors 156 + if r.Context().Err() != nil { 157 + log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err) 158 + return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()} 159 + } 160 + 161 + // Check for common "not found" patterns in error message 162 + errStr := err.Error() 163 + isNotFound := strings.Contains(errStr, "not found") || 164 + strings.Contains(errStr, "no rows") || 165 + strings.Contains(errStr, "unable to resolve") 166 + 167 + if isNotFound { 168 + return "", &actorNotFoundError{actor: actor} 169 + } 170 + 171 + // For other errors (network, database, DNS failures), return infrastructure error 172 + // This ensures users see "internal error" not "actor not found" for real problems 173 + log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err) 174 + return "", &resolutionFailedError{actor: actor, cause: err} 175 + } 176 + 177 + return did, nil 178 + } 179 + 180 + // populateViewerVoteState enriches comment views with the authenticated user's vote state 181 + func (h *GetCommentsHandler) populateViewerVoteState(r *http.Request, response *comments.GetActorCommentsResponse) { 182 + if h.voteService == nil || response == nil || len(response.Comments) == 0 { 183 + return 184 + } 185 + 186 + session := middleware.GetOAuthSession(r) 187 + if session == nil { 188 + return 189 + } 190 + 191 + userDID := middleware.GetUserDID(r) 192 + if userDID == "" { 193 + return 194 + } 195 + 196 + // Ensure vote cache is populated from PDS 197 + if err := h.voteService.EnsureCachePopulated(r.Context(), session); err != nil { 198 + log.Printf("Warning: failed to populate vote cache for actor comments: %v", err) 199 + return 200 + } 201 + 202 + // Collect comment URIs to batch lookup 203 + commentURIs := make([]string, 0, len(response.Comments)) 204 + for _, comment := range response.Comments { 205 + if comment != nil { 206 + commentURIs = append(commentURIs, comment.URI) 207 + } 208 + } 209 + 210 + // Get viewer votes for all comments 211 + viewerVotes := h.voteService.GetViewerVotesForSubjects(userDID, commentURIs) 212 + 213 + // Populate viewer state on each comment 214 + for _, comment := range response.Comments { 215 + if comment != nil { 216 + if vote, exists := viewerVotes[comment.URI]; exists { 217 + comment.Viewer = &comments.CommentViewerState{ 218 + Vote: &vote.Direction, 219 + VoteURI: &vote.URI, 220 + } 221 + } 222 + } 223 + } 224 + } 225 + 226 + // handleCommentServiceError maps service errors to HTTP responses 227 + func handleCommentServiceError(w http.ResponseWriter, err error) { 228 + if err == nil { 229 + return 230 + } 231 + 232 + errStr := err.Error() 233 + 234 + // Check for validation errors 235 + if strings.Contains(errStr, "invalid request") { 236 + writeError(w, http.StatusBadRequest, "InvalidRequest", errStr) 237 + return 238 + } 239 + 240 + // Check for not found errors 241 + if comments.IsNotFound(err) || strings.Contains(errStr, "not found") { 242 + writeError(w, http.StatusNotFound, "NotFound", "Resource not found") 243 + return 244 + } 245 + 246 + // Check for authorization errors 247 + if errors.Is(err, comments.ErrNotAuthorized) { 248 + writeError(w, http.StatusForbidden, "NotAuthorized", "Not authorized") 249 + return 250 + } 251 + 252 + // Default to internal server error 253 + log.Printf("ERROR: Comment service error: %v", err) 254 + writeError(w, http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") 255 + } 256 + 257 + // validationError represents a validation error for a specific field 258 + type validationError struct { 259 + field string 260 + message string 261 + } 262 + 263 + func (e *validationError) Error() string { 264 + return e.message 265 + }
+617
internal/api/handlers/actor/get_comments_test.go
··· 1 + package actor 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + "time" 11 + 12 + "Coves/internal/core/comments" 13 + "Coves/internal/core/posts" 14 + "Coves/internal/core/users" 15 + "Coves/internal/core/votes" 16 + 17 + oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 + ) 19 + 20 + // mockCommentService implements a comment service interface for testing 21 + type mockCommentService struct { 22 + getActorCommentsFunc func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) 23 + } 24 + 25 + func (m *mockCommentService) GetActorComments(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 26 + if m.getActorCommentsFunc != nil { 27 + return m.getActorCommentsFunc(ctx, req) 28 + } 29 + return &comments.GetActorCommentsResponse{ 30 + Comments: []*comments.CommentView{}, 31 + Cursor: nil, 32 + }, nil 33 + } 34 + 35 + // Implement other Service methods as no-ops 36 + func (m *mockCommentService) GetComments(ctx context.Context, req *comments.GetCommentsRequest) (*comments.GetCommentsResponse, error) { 37 + return nil, nil 38 + } 39 + 40 + func (m *mockCommentService) CreateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.CreateCommentRequest) (*comments.CreateCommentResponse, error) { 41 + return nil, nil 42 + } 43 + 44 + func (m *mockCommentService) UpdateComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.UpdateCommentRequest) (*comments.UpdateCommentResponse, error) { 45 + return nil, nil 46 + } 47 + 48 + func (m *mockCommentService) DeleteComment(ctx context.Context, session *oauthlib.ClientSessionData, req comments.DeleteCommentRequest) error { 49 + return nil 50 + } 51 + 52 + // mockUserServiceForComments implements users.UserService for testing getComments 53 + type mockUserServiceForComments struct { 54 + resolveHandleToDIDFunc func(ctx context.Context, handle string) (string, error) 55 + } 56 + 57 + func (m *mockUserServiceForComments) CreateUser(ctx context.Context, req users.CreateUserRequest) (*users.User, error) { 58 + return nil, nil 59 + } 60 + 61 + func (m *mockUserServiceForComments) GetUserByDID(ctx context.Context, did string) (*users.User, error) { 62 + return nil, nil 63 + } 64 + 65 + func (m *mockUserServiceForComments) GetUserByHandle(ctx context.Context, handle string) (*users.User, error) { 66 + return nil, nil 67 + } 68 + 69 + func (m *mockUserServiceForComments) UpdateHandle(ctx context.Context, did, newHandle string) (*users.User, error) { 70 + return nil, nil 71 + } 72 + 73 + func (m *mockUserServiceForComments) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 74 + if m.resolveHandleToDIDFunc != nil { 75 + return m.resolveHandleToDIDFunc(ctx, handle) 76 + } 77 + return "did:plc:testuser", nil 78 + } 79 + 80 + func (m *mockUserServiceForComments) RegisterAccount(ctx context.Context, req users.RegisterAccountRequest) (*users.RegisterAccountResponse, error) { 81 + return nil, nil 82 + } 83 + 84 + func (m *mockUserServiceForComments) IndexUser(ctx context.Context, did, handle, pdsURL string) error { 85 + return nil 86 + } 87 + 88 + func (m *mockUserServiceForComments) GetProfile(ctx context.Context, did string) (*users.ProfileViewDetailed, error) { 89 + return nil, nil 90 + } 91 + 92 + // mockVoteServiceForComments implements votes.Service for testing getComments 93 + type mockVoteServiceForComments struct{} 94 + 95 + func (m *mockVoteServiceForComments) CreateVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.CreateVoteRequest) (*votes.CreateVoteResponse, error) { 96 + return nil, nil 97 + } 98 + 99 + func (m *mockVoteServiceForComments) DeleteVote(ctx context.Context, session *oauthlib.ClientSessionData, req votes.DeleteVoteRequest) error { 100 + return nil 101 + } 102 + 103 + func (m *mockVoteServiceForComments) EnsureCachePopulated(ctx context.Context, session *oauthlib.ClientSessionData) error { 104 + return nil 105 + } 106 + 107 + func (m *mockVoteServiceForComments) GetViewerVote(userDID, subjectURI string) *votes.CachedVote { 108 + return nil 109 + } 110 + 111 + func (m *mockVoteServiceForComments) GetViewerVotesForSubjects(userDID string, subjectURIs []string) map[string]*votes.CachedVote { 112 + return nil 113 + } 114 + 115 + func TestGetCommentsHandler_Success(t *testing.T) { 116 + createdAt := time.Now().Format(time.RFC3339) 117 + indexedAt := time.Now().Format(time.RFC3339) 118 + 119 + mockComments := &mockCommentService{ 120 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 121 + return &comments.GetActorCommentsResponse{ 122 + Comments: []*comments.CommentView{ 123 + { 124 + URI: "at://did:plc:testuser/social.coves.community.comment/abc123", 125 + CID: "bafytest123", 126 + Content: "Test comment content", 127 + CreatedAt: createdAt, 128 + IndexedAt: indexedAt, 129 + Author: &posts.AuthorView{ 130 + DID: "did:plc:testuser", 131 + Handle: "test.user", 132 + }, 133 + Stats: &comments.CommentStats{ 134 + Upvotes: 5, 135 + Downvotes: 1, 136 + Score: 4, 137 + ReplyCount: 2, 138 + }, 139 + }, 140 + }, 141 + }, nil 142 + }, 143 + } 144 + mockUsers := &mockUserServiceForComments{} 145 + mockVotes := &mockVoteServiceForComments{} 146 + 147 + handler := NewGetCommentsHandler(mockComments, mockUsers, mockVotes) 148 + 149 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:testuser", nil) 150 + rec := httptest.NewRecorder() 151 + 152 + handler.HandleGetComments(rec, req) 153 + 154 + if rec.Code != http.StatusOK { 155 + t.Errorf("Expected status 200, got %d", rec.Code) 156 + } 157 + 158 + var response comments.GetActorCommentsResponse 159 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 160 + t.Fatalf("Failed to decode response: %v", err) 161 + } 162 + 163 + if len(response.Comments) != 1 { 164 + t.Errorf("Expected 1 comment in response, got %d", len(response.Comments)) 165 + } 166 + 167 + if response.Comments[0].URI != "at://did:plc:testuser/social.coves.community.comment/abc123" { 168 + t.Errorf("Expected correct comment URI, got '%s'", response.Comments[0].URI) 169 + } 170 + 171 + if response.Comments[0].Content != "Test comment content" { 172 + t.Errorf("Expected correct comment content, got '%s'", response.Comments[0].Content) 173 + } 174 + } 175 + 176 + func TestGetCommentsHandler_MissingActor(t *testing.T) { 177 + handler := NewGetCommentsHandler( 178 + &mockCommentService{}, 179 + &mockUserServiceForComments{}, 180 + &mockVoteServiceForComments{}, 181 + ) 182 + 183 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments", nil) 184 + rec := httptest.NewRecorder() 185 + 186 + handler.HandleGetComments(rec, req) 187 + 188 + if rec.Code != http.StatusBadRequest { 189 + t.Errorf("Expected status 400, got %d", rec.Code) 190 + } 191 + 192 + var response ErrorResponse 193 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 194 + t.Fatalf("Failed to decode response: %v", err) 195 + } 196 + 197 + if response.Error != "InvalidRequest" { 198 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 199 + } 200 + } 201 + 202 + func TestGetCommentsHandler_InvalidLimit(t *testing.T) { 203 + handler := NewGetCommentsHandler( 204 + &mockCommentService{}, 205 + &mockUserServiceForComments{}, 206 + &mockVoteServiceForComments{}, 207 + ) 208 + 209 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=abc", nil) 210 + rec := httptest.NewRecorder() 211 + 212 + handler.HandleGetComments(rec, req) 213 + 214 + if rec.Code != http.StatusBadRequest { 215 + t.Errorf("Expected status 400, got %d", rec.Code) 216 + } 217 + 218 + var response ErrorResponse 219 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 220 + t.Fatalf("Failed to decode response: %v", err) 221 + } 222 + 223 + if response.Error != "InvalidRequest" { 224 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 225 + } 226 + } 227 + 228 + func TestGetCommentsHandler_ActorNotFound(t *testing.T) { 229 + mockUsers := &mockUserServiceForComments{ 230 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 231 + return "", posts.ErrActorNotFound 232 + }, 233 + } 234 + 235 + handler := NewGetCommentsHandler( 236 + &mockCommentService{}, 237 + mockUsers, 238 + &mockVoteServiceForComments{}, 239 + ) 240 + 241 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=nonexistent.user", nil) 242 + rec := httptest.NewRecorder() 243 + 244 + handler.HandleGetComments(rec, req) 245 + 246 + if rec.Code != http.StatusNotFound { 247 + t.Errorf("Expected status 404, got %d", rec.Code) 248 + } 249 + 250 + var response ErrorResponse 251 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 252 + t.Fatalf("Failed to decode response: %v", err) 253 + } 254 + 255 + if response.Error != "ActorNotFound" { 256 + t.Errorf("Expected error 'ActorNotFound', got '%s'", response.Error) 257 + } 258 + } 259 + 260 + func TestGetCommentsHandler_ActorLengthExceedsMax(t *testing.T) { 261 + handler := NewGetCommentsHandler( 262 + &mockCommentService{}, 263 + &mockUserServiceForComments{}, 264 + &mockVoteServiceForComments{}, 265 + ) 266 + 267 + // Create an actor parameter that exceeds 2048 characters using valid URL characters 268 + longActorBytes := make([]byte, 2100) 269 + for i := range longActorBytes { 270 + longActorBytes[i] = 'a' 271 + } 272 + longActor := "did:plc:" + string(longActorBytes) 273 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor="+longActor, nil) 274 + rec := httptest.NewRecorder() 275 + 276 + handler.HandleGetComments(rec, req) 277 + 278 + if rec.Code != http.StatusBadRequest { 279 + t.Errorf("Expected status 400, got %d", rec.Code) 280 + } 281 + } 282 + 283 + func TestGetCommentsHandler_InvalidCursor(t *testing.T) { 284 + // The handleCommentServiceError function checks for "invalid request" in error message 285 + // to return a BadRequest. An invalid cursor error falls under this category. 286 + mockComments := &mockCommentService{ 287 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 288 + return nil, errors.New("invalid request: invalid cursor format") 289 + }, 290 + } 291 + 292 + handler := NewGetCommentsHandler( 293 + mockComments, 294 + &mockUserServiceForComments{}, 295 + &mockVoteServiceForComments{}, 296 + ) 297 + 298 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=invalid", nil) 299 + rec := httptest.NewRecorder() 300 + 301 + handler.HandleGetComments(rec, req) 302 + 303 + if rec.Code != http.StatusBadRequest { 304 + t.Errorf("Expected status 400, got %d", rec.Code) 305 + } 306 + 307 + var response ErrorResponse 308 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 309 + t.Fatalf("Failed to decode response: %v", err) 310 + } 311 + 312 + if response.Error != "InvalidRequest" { 313 + t.Errorf("Expected error 'InvalidRequest', got '%s'", response.Error) 314 + } 315 + } 316 + 317 + func TestGetCommentsHandler_MethodNotAllowed(t *testing.T) { 318 + handler := NewGetCommentsHandler( 319 + &mockCommentService{}, 320 + &mockUserServiceForComments{}, 321 + &mockVoteServiceForComments{}, 322 + ) 323 + 324 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.actor.getComments", nil) 325 + rec := httptest.NewRecorder() 326 + 327 + handler.HandleGetComments(rec, req) 328 + 329 + if rec.Code != http.StatusMethodNotAllowed { 330 + t.Errorf("Expected status 405, got %d", rec.Code) 331 + } 332 + } 333 + 334 + func TestGetCommentsHandler_HandleResolution(t *testing.T) { 335 + resolvedDID := "" 336 + mockComments := &mockCommentService{ 337 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 338 + resolvedDID = req.ActorDID 339 + return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil 340 + }, 341 + } 342 + mockUsers := &mockUserServiceForComments{ 343 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 344 + if handle == "test.user" { 345 + return "did:plc:resolveduser123", nil 346 + } 347 + return "", posts.ErrActorNotFound 348 + }, 349 + } 350 + 351 + handler := NewGetCommentsHandler( 352 + mockComments, 353 + mockUsers, 354 + &mockVoteServiceForComments{}, 355 + ) 356 + 357 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil) 358 + rec := httptest.NewRecorder() 359 + 360 + handler.HandleGetComments(rec, req) 361 + 362 + if rec.Code != http.StatusOK { 363 + t.Errorf("Expected status 200, got %d", rec.Code) 364 + } 365 + 366 + if resolvedDID != "did:plc:resolveduser123" { 367 + t.Errorf("Expected resolved DID 'did:plc:resolveduser123', got '%s'", resolvedDID) 368 + } 369 + } 370 + 371 + func TestGetCommentsHandler_DIDPassThrough(t *testing.T) { 372 + receivedDID := "" 373 + mockComments := &mockCommentService{ 374 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 375 + receivedDID = req.ActorDID 376 + return &comments.GetActorCommentsResponse{Comments: []*comments.CommentView{}}, nil 377 + }, 378 + } 379 + 380 + handler := NewGetCommentsHandler( 381 + mockComments, 382 + &mockUserServiceForComments{}, 383 + &mockVoteServiceForComments{}, 384 + ) 385 + 386 + // When actor is already a DID, it should pass through without resolution 387 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:directuser", nil) 388 + rec := httptest.NewRecorder() 389 + 390 + handler.HandleGetComments(rec, req) 391 + 392 + if rec.Code != http.StatusOK { 393 + t.Errorf("Expected status 200, got %d", rec.Code) 394 + } 395 + 396 + if receivedDID != "did:plc:directuser" { 397 + t.Errorf("Expected DID 'did:plc:directuser', got '%s'", receivedDID) 398 + } 399 + } 400 + 401 + func TestGetCommentsHandler_EmptyCommentsArray(t *testing.T) { 402 + mockComments := &mockCommentService{ 403 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 404 + return &comments.GetActorCommentsResponse{ 405 + Comments: []*comments.CommentView{}, 406 + }, nil 407 + }, 408 + } 409 + 410 + handler := NewGetCommentsHandler( 411 + mockComments, 412 + &mockUserServiceForComments{}, 413 + &mockVoteServiceForComments{}, 414 + ) 415 + 416 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:newuser", nil) 417 + rec := httptest.NewRecorder() 418 + 419 + handler.HandleGetComments(rec, req) 420 + 421 + if rec.Code != http.StatusOK { 422 + t.Errorf("Expected status 200, got %d", rec.Code) 423 + } 424 + 425 + var response comments.GetActorCommentsResponse 426 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 427 + t.Fatalf("Failed to decode response: %v", err) 428 + } 429 + 430 + if response.Comments == nil { 431 + t.Error("Expected comments array to be non-nil (empty array), got nil") 432 + } 433 + 434 + if len(response.Comments) != 0 { 435 + t.Errorf("Expected 0 comments for new user, got %d", len(response.Comments)) 436 + } 437 + } 438 + 439 + func TestGetCommentsHandler_WithCursor(t *testing.T) { 440 + receivedCursor := "" 441 + mockComments := &mockCommentService{ 442 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 443 + if req.Cursor != nil { 444 + receivedCursor = *req.Cursor 445 + } 446 + nextCursor := "page2cursor" 447 + return &comments.GetActorCommentsResponse{ 448 + Comments: []*comments.CommentView{}, 449 + Cursor: &nextCursor, 450 + }, nil 451 + }, 452 + } 453 + 454 + handler := NewGetCommentsHandler( 455 + mockComments, 456 + &mockUserServiceForComments{}, 457 + &mockVoteServiceForComments{}, 458 + ) 459 + 460 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&cursor=testcursor123", nil) 461 + rec := httptest.NewRecorder() 462 + 463 + handler.HandleGetComments(rec, req) 464 + 465 + if rec.Code != http.StatusOK { 466 + t.Errorf("Expected status 200, got %d", rec.Code) 467 + } 468 + 469 + if receivedCursor != "testcursor123" { 470 + t.Errorf("Expected cursor 'testcursor123', got '%s'", receivedCursor) 471 + } 472 + 473 + var response comments.GetActorCommentsResponse 474 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 475 + t.Fatalf("Failed to decode response: %v", err) 476 + } 477 + 478 + if response.Cursor == nil || *response.Cursor != "page2cursor" { 479 + t.Error("Expected response to include next cursor") 480 + } 481 + } 482 + 483 + func TestGetCommentsHandler_WithLimit(t *testing.T) { 484 + receivedLimit := 0 485 + mockComments := &mockCommentService{ 486 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 487 + receivedLimit = req.Limit 488 + return &comments.GetActorCommentsResponse{ 489 + Comments: []*comments.CommentView{}, 490 + }, nil 491 + }, 492 + } 493 + 494 + handler := NewGetCommentsHandler( 495 + mockComments, 496 + &mockUserServiceForComments{}, 497 + &mockVoteServiceForComments{}, 498 + ) 499 + 500 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&limit=25", nil) 501 + rec := httptest.NewRecorder() 502 + 503 + handler.HandleGetComments(rec, req) 504 + 505 + if rec.Code != http.StatusOK { 506 + t.Errorf("Expected status 200, got %d", rec.Code) 507 + } 508 + 509 + if receivedLimit != 25 { 510 + t.Errorf("Expected limit 25, got %d", receivedLimit) 511 + } 512 + } 513 + 514 + func TestGetCommentsHandler_WithCommunityFilter(t *testing.T) { 515 + receivedCommunity := "" 516 + mockComments := &mockCommentService{ 517 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 518 + receivedCommunity = req.Community 519 + return &comments.GetActorCommentsResponse{ 520 + Comments: []*comments.CommentView{}, 521 + }, nil 522 + }, 523 + } 524 + 525 + handler := NewGetCommentsHandler( 526 + mockComments, 527 + &mockUserServiceForComments{}, 528 + &mockVoteServiceForComments{}, 529 + ) 530 + 531 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test&community=did:plc:community123", nil) 532 + rec := httptest.NewRecorder() 533 + 534 + handler.HandleGetComments(rec, req) 535 + 536 + if rec.Code != http.StatusOK { 537 + t.Errorf("Expected status 200, got %d", rec.Code) 538 + } 539 + 540 + if receivedCommunity != "did:plc:community123" { 541 + t.Errorf("Expected community 'did:plc:community123', got '%s'", receivedCommunity) 542 + } 543 + } 544 + 545 + func TestGetCommentsHandler_ServiceError_Returns500(t *testing.T) { 546 + // Test that generic service errors (database failures, etc.) return 500 547 + mockComments := &mockCommentService{ 548 + getActorCommentsFunc: func(ctx context.Context, req *comments.GetActorCommentsRequest) (*comments.GetActorCommentsResponse, error) { 549 + return nil, errors.New("database connection failed") 550 + }, 551 + } 552 + 553 + handler := NewGetCommentsHandler( 554 + mockComments, 555 + &mockUserServiceForComments{}, 556 + &mockVoteServiceForComments{}, 557 + ) 558 + 559 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=did:plc:test", nil) 560 + rec := httptest.NewRecorder() 561 + 562 + handler.HandleGetComments(rec, req) 563 + 564 + if rec.Code != http.StatusInternalServerError { 565 + t.Errorf("Expected status 500, got %d", rec.Code) 566 + } 567 + 568 + var response ErrorResponse 569 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 570 + t.Fatalf("Failed to decode response: %v", err) 571 + } 572 + 573 + if response.Error != "InternalServerError" { 574 + t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) 575 + } 576 + 577 + // Verify error message doesn't leak internal details 578 + if response.Message == "database connection failed" { 579 + t.Error("Error message should not leak internal error details") 580 + } 581 + } 582 + 583 + func TestGetCommentsHandler_ResolutionFailedError_Returns500(t *testing.T) { 584 + // Test that infrastructure failures during handle resolution return 500, not 400 585 + mockUsers := &mockUserServiceForComments{ 586 + resolveHandleToDIDFunc: func(ctx context.Context, handle string) (string, error) { 587 + // Simulate a database failure during resolution 588 + return "", errors.New("connection refused") 589 + }, 590 + } 591 + 592 + handler := NewGetCommentsHandler( 593 + &mockCommentService{}, 594 + mockUsers, 595 + &mockVoteServiceForComments{}, 596 + ) 597 + 598 + // Use a handle (not a DID) to trigger resolution 599 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.actor.getComments?actor=test.user", nil) 600 + rec := httptest.NewRecorder() 601 + 602 + handler.HandleGetComments(rec, req) 603 + 604 + // Infrastructure failures should return 500, not 400 or 404 605 + if rec.Code != http.StatusInternalServerError { 606 + t.Errorf("Expected status 500 for infrastructure failure, got %d", rec.Code) 607 + } 608 + 609 + var response ErrorResponse 610 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 611 + t.Fatalf("Failed to decode response: %v", err) 612 + } 613 + 614 + if response.Error != "InternalServerError" { 615 + t.Errorf("Expected error 'InternalServerError', got '%s'", response.Error) 616 + } 617 + }
+60
internal/atproto/lexicon/social/coves/actor/getComments.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.actor.getComments", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a user's comments for their profile page.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { 13 + "type": "string", 14 + "format": "at-identifier", 15 + "description": "DID or handle of the user" 16 + }, 17 + "community": { 18 + "type": "string", 19 + "format": "at-identifier", 20 + "description": "Filter to comments in a specific community" 21 + }, 22 + "limit": { 23 + "type": "integer", 24 + "minimum": 1, 25 + "maximum": 100, 26 + "default": 50 27 + }, 28 + "cursor": { 29 + "type": "string" 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "required": ["comments"], 38 + "properties": { 39 + "comments": { 40 + "type": "array", 41 + "items": { 42 + "type": "ref", 43 + "ref": "social.coves.community.comment.defs#commentView" 44 + } 45 + }, 46 + "cursor": { 47 + "type": "string" 48 + } 49 + } 50 + } 51 + }, 52 + "errors": [ 53 + { 54 + "name": "NotFound", 55 + "description": "Actor not found" 56 + } 57 + ] 58 + } 59 + } 60 + }
+9
internal/core/comments/comment.go
··· 79 79 Neg *bool `json:"neg,omitempty"` 80 80 Val string `json:"val"` 81 81 } 82 + 83 + // ListByCommenterRequest defines the parameters for fetching a user's comments 84 + // Used by social.coves.actor.getComments endpoint 85 + type ListByCommenterRequest struct { 86 + CommenterDID string // Required: DID of the commenter 87 + CommunityDID *string // Optional: filter to comments in a specific community 88 + Limit int // Max comments to return (1-100) 89 + Cursor *string // Pagination cursor from previous response 90 + }
+132
internal/core/comments/comment_service.go
··· 46 46 // Supports hot, top, and new sorting with configurable depth and pagination 47 47 GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) 48 48 49 + // GetActorComments retrieves comments by a user for their profile page 50 + // Supports optional community filtering and cursor-based pagination 51 + GetActorComments(ctx context.Context, req *GetActorCommentsRequest) (*GetActorCommentsResponse, error) 52 + 49 53 // CreateComment creates a new comment or reply 50 54 CreateComment(ctx context.Context, session *oauth.ClientSessionData, req CreateCommentRequest) (*CreateCommentResponse, error) 51 55 ··· 1016 1020 return record 1017 1021 } 1018 1022 1023 + // GetActorComments retrieves comments by a user for their profile page 1024 + // Supports optional community filtering and cursor-based pagination 1025 + // Algorithm: 1026 + // 1. Validate and normalize request parameters (limit bounds) 1027 + // 2. Resolve community identifier to DID if provided 1028 + // 3. Fetch comments from repository with cursor-based pagination 1029 + // 4. Build CommentView for each comment with author info and stats 1030 + // 5. Return response with pagination cursor 1031 + func (s *commentService) GetActorComments(ctx context.Context, req *GetActorCommentsRequest) (*GetActorCommentsResponse, error) { 1032 + // 1. Validate and normalize request 1033 + if err := validateGetActorCommentsRequest(req); err != nil { 1034 + return nil, fmt.Errorf("invalid request: %w", err) 1035 + } 1036 + 1037 + // Add timeout to prevent runaway queries 1038 + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 1039 + defer cancel() 1040 + 1041 + // 2. Resolve community identifier to DID if provided 1042 + var communityDID *string 1043 + if req.Community != "" { 1044 + // Check if it's already a DID 1045 + if strings.HasPrefix(req.Community, "did:") { 1046 + communityDID = &req.Community 1047 + } else { 1048 + // It's a handle - resolve to DID via community repository 1049 + community, err := s.communityRepo.GetByHandle(ctx, req.Community) 1050 + if err != nil { 1051 + // If community not found, return empty results rather than error 1052 + // This matches behavior of other endpoints 1053 + if errors.Is(err, communities.ErrCommunityNotFound) { 1054 + return &GetActorCommentsResponse{ 1055 + Comments: []*CommentView{}, 1056 + Cursor: nil, 1057 + }, nil 1058 + } 1059 + return nil, fmt.Errorf("failed to resolve community: %w", err) 1060 + } 1061 + communityDID = &community.DID 1062 + } 1063 + } 1064 + 1065 + // 3. Fetch comments from repository 1066 + repoReq := ListByCommenterRequest{ 1067 + CommenterDID: req.ActorDID, 1068 + CommunityDID: communityDID, 1069 + Limit: req.Limit, 1070 + Cursor: req.Cursor, 1071 + } 1072 + 1073 + dbComments, nextCursor, err := s.commentRepo.ListByCommenterWithCursor(ctx, repoReq) 1074 + if err != nil { 1075 + return nil, fmt.Errorf("failed to fetch comments: %w", err) 1076 + } 1077 + 1078 + // 4. Build CommentViews for each comment 1079 + // Batch fetch vote states if viewer is authenticated 1080 + var voteStates map[string]interface{} 1081 + if req.ViewerDID != nil && len(dbComments) > 0 { 1082 + commentURIs := make([]string, 0, len(dbComments)) 1083 + for _, comment := range dbComments { 1084 + commentURIs = append(commentURIs, comment.URI) 1085 + } 1086 + 1087 + var err error 1088 + voteStates, err = s.commentRepo.GetVoteStateForComments(ctx, *req.ViewerDID, commentURIs) 1089 + if err != nil { 1090 + // Log error but don't fail the request - vote state is optional 1091 + log.Printf("Warning: Failed to fetch vote states for actor comments: %v", err) 1092 + } 1093 + } 1094 + 1095 + // Batch fetch user data for comment authors (should all be the same user, but handle consistently) 1096 + usersByDID := make(map[string]*users.User) 1097 + if len(dbComments) > 0 { 1098 + // For actor comments, all comments are by the same user 1099 + // But we still use the batch pattern for consistency with other methods 1100 + user, err := s.userRepo.GetByDID(ctx, req.ActorDID) 1101 + if err != nil { 1102 + // Log error but don't fail request - user data is optional 1103 + log.Printf("Warning: Failed to fetch user for actor %s: %v", req.ActorDID, err) 1104 + } else if user != nil { 1105 + usersByDID[user.DID] = user 1106 + } 1107 + } 1108 + 1109 + // Build comment views 1110 + commentViews := make([]*CommentView, 0, len(dbComments)) 1111 + for _, comment := range dbComments { 1112 + commentView := s.buildCommentView(comment, req.ViewerDID, voteStates, usersByDID) 1113 + commentViews = append(commentViews, commentView) 1114 + } 1115 + 1116 + // 5. Return response with comments and cursor 1117 + return &GetActorCommentsResponse{ 1118 + Comments: commentViews, 1119 + Cursor: nextCursor, 1120 + }, nil 1121 + } 1122 + 1123 + // validateGetActorCommentsRequest validates and normalizes request parameters 1124 + // Applies default values and enforces bounds per API specification 1125 + func validateGetActorCommentsRequest(req *GetActorCommentsRequest) error { 1126 + if req == nil { 1127 + return errors.New("request cannot be nil") 1128 + } 1129 + 1130 + // ActorDID is required 1131 + if req.ActorDID == "" { 1132 + return errors.New("actor DID is required") 1133 + } 1134 + 1135 + // Validate DID format 1136 + if !strings.HasPrefix(req.ActorDID, "did:") { 1137 + return errors.New("invalid actor DID format") 1138 + } 1139 + 1140 + // Apply limit defaults and bounds (1-100, default 50) 1141 + if req.Limit <= 0 { 1142 + req.Limit = 50 1143 + } 1144 + if req.Limit > 100 { 1145 + req.Limit = 100 1146 + } 1147 + 1148 + return nil 1149 + } 1150 + 1019 1151 // validateGetCommentsRequest validates and normalizes request parameters 1020 1152 // Applies default values and enforces bounds per API specification 1021 1153 func validateGetCommentsRequest(req *GetCommentsRequest) error {
+6 -1
internal/core/comments/interfaces.go
··· 50 50 CountByParent(ctx context.Context, parentURI string) (int, error) 51 51 52 52 // ListByCommenter retrieves all comments by a specific user 53 - // Future: Used for user comment history 53 + // Deprecated: Use ListByCommenterWithCursor for cursor-based pagination 54 54 ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error) 55 55 56 + // ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination 57 + // Used for user profile comment history (social.coves.actor.getComments) 58 + // Supports optional community filtering and returns next page cursor 59 + ListByCommenterWithCursor(ctx context.Context, req ListByCommenterRequest) ([]*Comment, *string, error) 60 + 56 61 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 57 62 // Supports hot, top, and new sorting with cursor-based pagination 58 63 // Returns comments with author info hydrated and next page cursor
+17
internal/core/comments/view_models.go
··· 67 67 Cursor *string `json:"cursor,omitempty"` 68 68 Comments []*ThreadViewComment `json:"comments"` 69 69 } 70 + 71 + // GetActorCommentsRequest defines the parameters for fetching a user's comments 72 + // Used by social.coves.actor.getComments endpoint 73 + type GetActorCommentsRequest struct { 74 + ActorDID string // Required: DID of the commenter 75 + Community string // Optional: filter to comments in a specific community (handle or DID) 76 + Limit int // Max comments to return (1-100, default 50) 77 + Cursor *string // Pagination cursor from previous response 78 + ViewerDID *string // Optional: DID of the viewer for populating viewer state 79 + } 80 + 81 + // GetActorCommentsResponse represents the response for fetching a user's comments 82 + // Matches social.coves.actor.getComments lexicon output 83 + type GetActorCommentsResponse struct { 84 + Comments []*CommentView `json:"comments"` 85 + Cursor *string `json:"cursor,omitempty"` 86 + }
+151
internal/db/postgres/comment_repo.go
··· 410 410 return result, nil 411 411 } 412 412 413 + // ListByCommenterWithCursor retrieves comments by a user with cursor-based pagination 414 + // Used for user profile comment history (social.coves.actor.getComments) 415 + // Supports optional community filtering and returns next page cursor 416 + // Uses chronological ordering (newest first) with composite key cursor for stable pagination 417 + func (r *postgresCommentRepo) ListByCommenterWithCursor(ctx context.Context, req comments.ListByCommenterRequest) ([]*comments.Comment, *string, error) { 418 + // Parse cursor for pagination 419 + cursorFilter, cursorValues, err := r.parseCommenterCursor(req.Cursor) 420 + if err != nil { 421 + return nil, nil, fmt.Errorf("invalid cursor: %w", err) 422 + } 423 + 424 + // Build community filter if provided 425 + // Parameter numbering: $1=commenterDID, $2=limit+1 (for pagination detection) 426 + // Cursor values (if present) use $3 and $4, community DID comes after 427 + var communityFilter string 428 + var communityValue []interface{} 429 + paramOffset := 2 + len(cursorValues) // Start after $1, $2, and any cursor params 430 + if req.CommunityDID != nil && *req.CommunityDID != "" { 431 + paramOffset++ 432 + communityFilter = fmt.Sprintf("AND c.root_uri IN (SELECT uri FROM posts WHERE community_did = $%d)", paramOffset) 433 + communityValue = append(communityValue, *req.CommunityDID) 434 + } 435 + 436 + // Build complete query with JOINs and filters 437 + // LEFT JOIN prevents data loss when user record hasn't been indexed yet 438 + query := fmt.Sprintf(` 439 + SELECT 440 + c.id, c.uri, c.cid, c.rkey, c.commenter_did, 441 + c.root_uri, c.root_cid, c.parent_uri, c.parent_cid, 442 + c.content, c.content_facets, c.embed, c.content_labels, c.langs, 443 + c.created_at, c.indexed_at, c.deleted_at, c.deletion_reason, c.deleted_by, 444 + c.upvote_count, c.downvote_count, c.score, c.reply_count, 445 + COALESCE(u.handle, c.commenter_did) as author_handle 446 + FROM comments c 447 + LEFT JOIN users u ON c.commenter_did = u.did 448 + WHERE c.commenter_did = $1 449 + AND c.deleted_at IS NULL 450 + %s 451 + %s 452 + ORDER BY c.created_at DESC, c.uri DESC 453 + LIMIT $2 454 + `, communityFilter, cursorFilter) 455 + 456 + // Prepare query arguments 457 + args := []interface{}{req.CommenterDID, req.Limit + 1} // +1 to detect next page 458 + args = append(args, cursorValues...) 459 + args = append(args, communityValue...) 460 + 461 + // Execute query 462 + rows, err := r.db.QueryContext(ctx, query, args...) 463 + if err != nil { 464 + return nil, nil, fmt.Errorf("failed to query comments by commenter: %w", err) 465 + } 466 + defer func() { 467 + if err := rows.Close(); err != nil { 468 + log.Printf("Failed to close rows: %v", err) 469 + } 470 + }() 471 + 472 + // Scan results 473 + var result []*comments.Comment 474 + for rows.Next() { 475 + var comment comments.Comment 476 + var langs pq.StringArray 477 + var authorHandle string 478 + 479 + err := rows.Scan( 480 + &comment.ID, &comment.URI, &comment.CID, &comment.RKey, &comment.CommenterDID, 481 + &comment.RootURI, &comment.RootCID, &comment.ParentURI, &comment.ParentCID, 482 + &comment.Content, &comment.ContentFacets, &comment.Embed, &comment.ContentLabels, &langs, 483 + &comment.CreatedAt, &comment.IndexedAt, &comment.DeletedAt, &comment.DeletionReason, &comment.DeletedBy, 484 + &comment.UpvoteCount, &comment.DownvoteCount, &comment.Score, &comment.ReplyCount, 485 + &authorHandle, 486 + ) 487 + if err != nil { 488 + return nil, nil, fmt.Errorf("failed to scan comment: %w", err) 489 + } 490 + 491 + comment.Langs = langs 492 + comment.CommenterHandle = authorHandle 493 + result = append(result, &comment) 494 + } 495 + 496 + if err = rows.Err(); err != nil { 497 + return nil, nil, fmt.Errorf("error iterating comments: %w", err) 498 + } 499 + 500 + // Handle pagination cursor 501 + var nextCursor *string 502 + if len(result) > req.Limit && req.Limit > 0 { 503 + result = result[:req.Limit] 504 + lastComment := result[len(result)-1] 505 + cursorStr := r.buildCommenterCursor(lastComment) 506 + nextCursor = &cursorStr 507 + } 508 + 509 + return result, nextCursor, nil 510 + } 511 + 512 + // parseCommenterCursor decodes pagination cursor for commenter comments 513 + // Cursor format: createdAt|uri (same as "new" sort for other comment queries) 514 + // 515 + // IMPORTANT: This function returns a filter string with hardcoded parameter numbers ($3, $4). 516 + // The caller (ListByCommenterWithCursor) must ensure parameters are ordered as: 517 + // $1=commenterDID, $2=limit+1, $3=createdAt, $4=uri, then community DID if present. 518 + // If you modify the parameter order in the caller, you must update the filter here. 519 + func (r *postgresCommentRepo) parseCommenterCursor(cursor *string) (string, []interface{}, error) { 520 + if cursor == nil || *cursor == "" { 521 + return "", nil, nil 522 + } 523 + 524 + // Validate cursor size to prevent DoS via massive base64 strings 525 + const maxCursorSize = 1024 526 + if len(*cursor) > maxCursorSize { 527 + return "", nil, fmt.Errorf("cursor too large: maximum %d bytes", maxCursorSize) 528 + } 529 + 530 + // Decode base64 cursor 531 + decoded, err := base64.URLEncoding.DecodeString(*cursor) 532 + if err != nil { 533 + return "", nil, fmt.Errorf("invalid cursor encoding") 534 + } 535 + 536 + // Parse cursor: createdAt|uri 537 + parts := strings.Split(string(decoded), "|") 538 + if len(parts) != 2 { 539 + return "", nil, fmt.Errorf("invalid cursor format") 540 + } 541 + 542 + createdAt := parts[0] 543 + uri := parts[1] 544 + 545 + // Validate AT-URI format 546 + if !strings.HasPrefix(uri, "at://") { 547 + return "", nil, fmt.Errorf("invalid cursor URI") 548 + } 549 + 550 + filter := `AND (c.created_at < $3 OR (c.created_at = $3 AND c.uri < $4))` 551 + return filter, []interface{}{createdAt, uri}, nil 552 + } 553 + 554 + // buildCommenterCursor creates pagination cursor from last comment 555 + // Uses createdAt|uri format for stable pagination 556 + func (r *postgresCommentRepo) buildCommenterCursor(comment *comments.Comment) string { 557 + cursorStr := fmt.Sprintf("%s|%s", 558 + comment.CreatedAt.Format("2006-01-02T15:04:05.999999999Z07:00"), 559 + comment.URI) 560 + return base64.URLEncoding.EncodeToString([]byte(cursorStr)) 561 + } 562 + 413 563 // ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination 414 564 // Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at) 415 565 // Uses cursor-based pagination with composite keys for consistent ordering ··· 964 1114 // If votes table doesn't exist yet, return empty map instead of error 965 1115 // This allows the API to work before votes indexing is fully implemented 966 1116 if strings.Contains(err.Error(), "does not exist") { 1117 + log.Printf("WARN: Votes table does not exist, returning empty vote state for %d comments", len(commentURIs)) 967 1118 return make(map[string]interface{}), nil 968 1119 } 969 1120 return nil, fmt.Errorf("failed to get vote state for comments: %w", err)
+14 -56
internal/api/handlers/community/block.go
··· 47 47 return 48 48 } 49 49 50 - // Extract authenticated user DID and access token from request context (injected by auth middleware) 51 - userDID := middleware.GetUserDID(r) 52 - if userDID == "" { 50 + // Get OAuth session from context (injected by auth middleware) 51 + // The session contains the user's DID and credentials needed for DPoP authentication 52 + session := middleware.GetOAuthSession(r) 53 + if session == nil { 53 54 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 54 55 return 55 56 } 56 57 57 - userAccessToken := middleware.GetUserAccessToken(r) 58 - if userAccessToken == "" { 59 - writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") 60 - return 61 - } 62 - 63 - // Resolve community identifier (handle or DID) to DID 64 - // This allows users to block by handle: @gaming.community.coves.social or !gaming@coves.social 65 - communityDID, err := h.service.ResolveCommunityIdentifier(r.Context(), req.Community) 66 - if err != nil { 67 - if communities.IsNotFound(err) { 68 - writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found") 69 - return 70 - } 71 - if communities.IsValidationError(err) { 72 - writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 73 - return 74 - } 75 - log.Printf("Failed to resolve community identifier %s: %v", req.Community, err) 76 - writeError(w, http.StatusInternalServerError, "InternalError", "Failed to resolve community") 77 - return 78 - } 79 - 80 - // Block via service (write-forward to PDS) using resolved DID 81 - block, err := h.service.BlockCommunity(r.Context(), userDID, userAccessToken, communityDID) 58 + // Block via service (write-forward to PDS with DPoP authentication) 59 + // Service handles identifier resolution (DIDs, handles, scoped identifiers) 60 + block, err := h.service.BlockCommunity(r.Context(), session, req.Community) 82 61 if err != nil { 83 62 handleServiceError(w, err) 84 63 return ··· 125 104 return 126 105 } 127 106 128 - // Extract authenticated user DID and access token from request context (injected by auth middleware) 129 - userDID := middleware.GetUserDID(r) 130 - if userDID == "" { 107 + // Get OAuth session from context (injected by auth middleware) 108 + // The session contains the user's DID and credentials needed for DPoP authentication 109 + session := middleware.GetOAuthSession(r) 110 + if session == nil { 131 111 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 132 112 return 133 113 } 134 114 135 - userAccessToken := middleware.GetUserAccessToken(r) 136 - if userAccessToken == "" { 137 - writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") 138 - return 139 - } 140 - 141 - // Resolve community identifier (handle or DID) to DID 142 - // This allows users to unblock by handle: @gaming.community.coves.social or !gaming@coves.social 143 - communityDID, err := h.service.ResolveCommunityIdentifier(r.Context(), req.Community) 144 - if err != nil { 145 - if communities.IsNotFound(err) { 146 - writeError(w, http.StatusNotFound, "CommunityNotFound", "Community not found") 147 - return 148 - } 149 - if communities.IsValidationError(err) { 150 - writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 151 - return 152 - } 153 - log.Printf("Failed to resolve community identifier %s: %v", req.Community, err) 154 - writeError(w, http.StatusInternalServerError, "InternalError", "Failed to resolve community") 155 - return 156 - } 157 - 158 - // Unblock via service (delete record on PDS) using resolved DID 159 - err = h.service.UnblockCommunity(r.Context(), userDID, userAccessToken, communityDID) 115 + // Unblock via service (delete record on PDS with DPoP authentication) 116 + // Service handles identifier resolution (DIDs, handles, scoped identifiers) 117 + err := h.service.UnblockCommunity(r.Context(), session, req.Community) 160 118 if err != nil { 161 119 handleServiceError(w, err) 162 120 return
+6 -4
internal/api/handlers/community/create_test.go
··· 10 10 "net/http/httptest" 11 11 "testing" 12 12 "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 15 ) 14 16 15 17 // mockCommunityService implements communities.Service for testing ··· 49 51 return nil, 0, nil 50 52 } 51 53 52 - func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 54 + func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 53 55 return nil, nil 54 56 } 55 57 56 - func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error { 58 + func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 57 59 return nil 58 60 } 59 61 ··· 65 67 return nil, nil 66 68 } 67 69 68 - func (m *mockCommunityService) BlockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) (*communities.CommunityBlock, error) { 70 + func (m *mockCommunityService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 69 71 return nil, nil 70 72 } 71 73 72 - func (m *mockCommunityService) UnblockCommunity(ctx context.Context, userDID, accessToken, communityIdentifier string) error { 74 + func (m *mockCommunityService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 73 75 return nil 74 76 } 75 77
+12 -23
internal/api/handlers/community/subscribe.go
··· 51 51 return 52 52 } 53 53 54 - // Extract authenticated user DID and access token from request context (injected by auth middleware) 55 - // Note: contentVisibility defaults and clamping handled by service layer 56 - userDID := middleware.GetUserDID(r) 57 - if userDID == "" { 54 + // Get OAuth session from context (injected by auth middleware) 55 + // The session contains the user's DID and credentials needed for DPoP authentication 56 + session := middleware.GetOAuthSession(r) 57 + if session == nil { 58 58 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 59 59 return 60 60 } 61 61 62 - userAccessToken := middleware.GetUserAccessToken(r) 63 - if userAccessToken == "" { 64 - writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") 65 - return 66 - } 67 - 68 - // Subscribe via service (write-forward to PDS) 62 + // Subscribe via service (write-forward to PDS with DPoP authentication) 69 63 // Service handles identifier resolution (DIDs, handles, scoped identifiers) 70 - subscription, err := h.service.SubscribeToCommunity(r.Context(), userDID, userAccessToken, req.Community, req.ContentVisibility) 64 + subscription, err := h.service.SubscribeToCommunity(r.Context(), session, req.Community, req.ContentVisibility) 71 65 if err != nil { 72 66 handleServiceError(w, err) 73 67 return ··· 117 111 return 118 112 } 119 113 120 - // Extract authenticated user DID and access token from request context (injected by auth middleware) 121 - userDID := middleware.GetUserDID(r) 122 - if userDID == "" { 114 + // Get OAuth session from context (injected by auth middleware) 115 + // The session contains the user's DID and credentials needed for DPoP authentication 116 + session := middleware.GetOAuthSession(r) 117 + if session == nil { 123 118 writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 124 119 return 125 120 } 126 121 127 - userAccessToken := middleware.GetUserAccessToken(r) 128 - if userAccessToken == "" { 129 - writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token") 130 - return 131 - } 132 - 133 - // Unsubscribe via service (delete record on PDS) 122 + // Unsubscribe via service (delete record on PDS with DPoP authentication) 134 123 // Service handles identifier resolution (DIDs, handles, scoped identifiers) 135 - err := h.service.UnsubscribeFromCommunity(r.Context(), userDID, userAccessToken, req.Community) 124 + err := h.service.UnsubscribeFromCommunity(r.Context(), session, req.Community) 136 125 if err != nil { 137 126 handleServiceError(w, err) 138 127 return
+5
internal/atproto/pds/errors.go
··· 27 27 func IsAuthError(err error) bool { 28 28 return errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden) 29 29 } 30 + 31 + // IsConflictError returns true if the error indicates a conflict (e.g., duplicate record). 32 + func IsConflictError(err error) bool { 33 + return errors.Is(err, ErrConflict) 34 + }
+3 -5
tests/e2e/user_signup_test.go
··· 391 391 } 392 392 393 393 var result struct { 394 - DID string `json:"did"` 395 - Profile struct { 396 - Handle string `json:"handle"` 397 - } `json:"profile"` 394 + DID string `json:"did"` 395 + Handle string `json:"handle"` 398 396 } 399 397 400 398 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 401 399 return "", "", fmt.Errorf("failed to decode response: %w", err) 402 400 } 403 401 404 - return result.DID, result.Profile.Handle, nil 402 + return result.DID, result.Handle, nil 405 403 }
+1 -1
tests/integration/aggregator_e2e_test.go
··· 68 68 identityConfig := identity.DefaultConfig() 69 69 identityResolver := identity.NewResolver(db, identityConfig) 70 70 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001") 71 - communityService := communities.NewCommunityService(communityRepo, "http://localhost:3001", "did:web:test.coves.social", "coves.social", nil) 71 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, "http://localhost:3001", "did:web:test.coves.social", "coves.social", nil, nil) 72 72 aggregatorService := aggregators.NewAggregatorService(aggregatorRepo, communityService) 73 73 postService := posts.NewPostService(postRepo, communityService, aggregatorService, nil, nil, nil, "http://localhost:3001") 74 74
+5 -5
tests/integration/author_posts_e2e_test.go
··· 71 71 // Setup services 72 72 resolver := identity.NewResolver(db, identity.DefaultConfig()) 73 73 userService := users.NewUserService(userRepo, resolver, pdsURL) 74 - communityService := communities.NewCommunityService(communityRepo, pdsURL, getTestInstanceDID(), "", nil) 74 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil) 75 75 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) 76 76 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 77 77 ··· 289 289 290 290 resolver := identity.NewResolver(db, identity.DefaultConfig()) 291 291 userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 292 - communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil) 292 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil) 293 293 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 294 294 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 295 295 ··· 428 428 429 429 resolver := identity.NewResolver(db, identity.DefaultConfig()) 430 430 userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 431 - communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil) 431 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil) 432 432 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 433 433 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 434 434 ··· 548 548 // Setup services 549 549 resolver := identity.NewResolver(db, identity.DefaultConfig()) 550 550 userService := users.NewUserService(userRepo, resolver, pdsURL) 551 - communityService := communities.NewCommunityService(communityRepo, pdsURL, getTestInstanceDID(), "", nil) 551 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, pdsURL, getTestInstanceDID(), "", nil, nil) 552 552 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) 553 553 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 554 554 ··· 658 658 659 659 resolver := identity.NewResolver(db, identity.DefaultConfig()) 660 660 userService := users.NewUserService(userRepo, resolver, getTestPDSURL()) 661 - communityService := communities.NewCommunityService(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil) 661 + communityService := communities.NewCommunityServiceWithPDSFactory(communityRepo, getTestPDSURL(), getTestInstanceDID(), "", nil, nil) 662 662 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, getTestPDSURL()) 663 663 voteService := votes.NewServiceWithPDSFactory(voteRepo, nil, nil, PasswordAuthPDSClientFactory()) 664 664
+21 -5
tests/integration/block_handle_resolution_test.go
··· 13 13 "testing" 14 14 15 15 postgresRepo "Coves/internal/db/postgres" 16 + 17 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 16 19 ) 17 20 21 + // createTestOAuthSessionForBlock creates a mock OAuth session for block handler tests 22 + func createTestOAuthSessionForBlock(did string) *oauth.ClientSessionData { 23 + parsedDID, _ := syntax.ParseDID(did) 24 + return &oauth.ClientSessionData{ 25 + AccountDID: parsedDID, 26 + SessionID: "test-session", 27 + HostURL: "http://localhost:3001", 28 + AccessToken: "test-access-token", 29 + } 30 + } 31 + 18 32 // TestBlockHandler_HandleResolution tests that the block handler accepts handles 19 33 // in addition to DIDs and resolves them correctly 20 34 func TestBlockHandler_HandleResolution(t *testing.T) { ··· 29 43 30 44 // Set up repositories and services 31 45 communityRepo := postgresRepo.NewCommunityRepository(db) 32 - communityService := communities.NewCommunityService( 46 + communityService := communities.NewCommunityServiceWithPDSFactory( 33 47 communityRepo, 34 48 getTestPDSURL(), 35 49 getTestInstanceDID(), 36 50 "coves.social", 37 51 nil, // No PDS HTTP client for this test 52 + nil, // No PDS factory needed for this test 38 53 ) 39 54 40 55 blockHandler := community.NewBlockHandler(communityService) ··· 193 208 req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(reqJSON)) 194 209 req.Header.Set("Content-Type", "application/json") 195 210 196 - // Add auth context so we get past auth checks and test resolution validation 197 - ctx := context.WithValue(req.Context(), middleware.UserDIDKey, "did:plc:test123") 198 - ctx = context.WithValue(ctx, middleware.UserAccessToken, "test-token") 211 + // Add OAuth session context so we get past auth checks and test resolution validation 212 + session := createTestOAuthSessionForBlock("did:plc:test123") 213 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 199 214 req = req.WithContext(ctx) 200 215 201 216 w := httptest.NewRecorder() ··· 265 280 266 281 // Set up repositories and services 267 282 communityRepo := postgresRepo.NewCommunityRepository(db) 268 - communityService := communities.NewCommunityService( 283 + communityService := communities.NewCommunityServiceWithPDSFactory( 269 284 communityRepo, 270 285 getTestPDSURL(), 271 286 getTestInstanceDID(), 272 287 "coves.social", 273 288 nil, 289 + nil, // No PDS factory needed for this test 274 290 ) 275 291 276 292 blockHandler := community.NewBlockHandler(communityService)
+8 -4
tests/integration/community_identifier_resolution_test.go
··· 50 50 instanceDID = "did:web:" + instanceDomain 51 51 } 52 52 53 - service := communities.NewCommunityService( 53 + service := communities.NewCommunityServiceWithPDSFactory( 54 54 repo, 55 55 pdsURL, 56 56 instanceDID, 57 57 instanceDomain, 58 58 provisioner, 59 + nil, 59 60 ) 60 61 61 62 // Create a test community to resolve ··· 244 245 } 245 246 246 247 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 247 - service := communities.NewCommunityService( 248 + service := communities.NewCommunityServiceWithPDSFactory( 248 249 repo, 249 250 pdsURL, 250 251 instanceDID, 251 252 instanceDomain, 252 253 provisioner, 254 + nil, 253 255 ) 254 256 255 257 tests := []struct { ··· 421 423 } 422 424 423 425 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 424 - service := communities.NewCommunityService( 426 + service := communities.NewCommunityServiceWithPDSFactory( 425 427 repo, 426 428 pdsURL, 427 429 instanceDID, 428 430 instanceDomain, 429 431 provisioner, 432 + nil, 430 433 ) 431 434 432 435 t.Run("DID error includes identifier", func(t *testing.T) { ··· 486 489 } 487 490 488 491 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 489 - service := communities.NewCommunityService( 492 + service := communities.NewCommunityServiceWithPDSFactory( 490 493 repo, 491 494 pdsURL, 492 495 instanceDID, 493 496 instanceDomain, 494 497 provisioner, 498 + nil, 495 499 ) 496 500 497 501 // Create a test community
+2 -1
tests/integration/community_provisioning_test.go
··· 146 146 147 147 repo := postgres.NewCommunityRepository(db) 148 148 provisioner := communities.NewPDSAccountProvisioner("test.local", "http://localhost:3001") 149 - service := communities.NewCommunityService( 149 + service := communities.NewCommunityServiceWithPDSFactory( 150 150 repo, 151 151 "http://localhost:3001", // pdsURL 152 152 "did:web:test.local", // instanceDID 153 153 "test.local", // instanceDomain 154 154 provisioner, 155 + nil, 155 156 ) 156 157 ctx := context.Background() 157 158
+10 -5
tests/integration/community_service_integration_test.go
··· 57 57 // Create provisioner and service (production code path) 58 58 // Use coves.social domain (configured in PDS_SERVICE_HANDLE_DOMAINS as c-{name}.coves.social) 59 59 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 60 - service := communities.NewCommunityService( 60 + service := communities.NewCommunityServiceWithPDSFactory( 61 61 repo, 62 62 pdsURL, 63 63 "did:web:coves.social", 64 64 "coves.social", 65 65 provisioner, 66 + nil, 66 67 ) 67 68 68 69 // Generate unique community name (keep short for DNS label limit) ··· 201 202 202 203 t.Run("handles PDS errors gracefully", func(t *testing.T) { 203 204 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 204 - service := communities.NewCommunityService( 205 + service := communities.NewCommunityServiceWithPDSFactory( 205 206 repo, 206 207 pdsURL, 207 208 "did:web:coves.social", 208 209 "coves.social", 209 210 provisioner, 211 + nil, 210 212 ) 211 213 212 214 // Try to create community with invalid name (should fail validation before PDS) ··· 232 234 233 235 t.Run("validates DNS label limits", func(t *testing.T) { 234 236 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 235 - service := communities.NewCommunityService( 237 + service := communities.NewCommunityServiceWithPDSFactory( 236 238 repo, 237 239 pdsURL, 238 240 "did:web:coves.social", 239 241 "coves.social", 240 242 provisioner, 243 + nil, 241 244 ) 242 245 243 246 // Try 64-char name (exceeds DNS limit of 63) ··· 301 304 repo := postgres.NewCommunityRepository(db) 302 305 303 306 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 304 - service := communities.NewCommunityService( 307 + service := communities.NewCommunityServiceWithPDSFactory( 305 308 repo, 306 309 pdsURL, 307 310 "did:web:coves.social", 308 311 "coves.social", 309 312 provisioner, 313 + nil, 310 314 ) 311 315 312 316 t.Run("updates community with real PDS", func(t *testing.T) { ··· 492 496 repo := postgres.NewCommunityRepository(db) 493 497 494 498 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 495 - service := communities.NewCommunityService( 499 + service := communities.NewCommunityServiceWithPDSFactory( 496 500 repo, 497 501 pdsURL, 498 502 "did:web:coves.social", 499 503 "coves.social", 500 504 provisioner, 505 + nil, 501 506 ) 502 507 503 508 t.Run("generated password works for session creation", func(t *testing.T) {
+2 -1
tests/integration/community_update_e2e_test.go
··· 88 88 // Setup services 89 89 communityRepo := postgres.NewCommunityRepository(db) 90 90 provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 91 - communityService := communities.NewCommunityService( 91 + communityService := communities.NewCommunityServiceWithPDSFactory( 92 92 communityRepo, 93 93 pdsURL, 94 94 instanceDID, 95 95 "coves.social", 96 96 provisioner, 97 + nil, 97 98 ) 98 99 99 100 consumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, true, identityResolver)
+17
tests/integration/helpers.go
··· 4 4 "Coves/internal/api/middleware" 5 5 "Coves/internal/atproto/oauth" 6 6 "Coves/internal/atproto/pds" 7 + "Coves/internal/core/communities" 7 8 "Coves/internal/core/users" 8 9 "Coves/internal/core/votes" 9 10 "bytes" ··· 443 444 return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 444 445 } 445 446 } 447 + 448 + // CommunityPasswordAuthPDSClientFactory creates a PDSClientFactory for communities that uses password-based Bearer auth. 449 + // This is for E2E tests that use createSession instead of OAuth. 450 + // The factory extracts the access token and host URL from the session data. 451 + func CommunityPasswordAuthPDSClientFactory() communities.PDSClientFactory { 452 + return func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) { 453 + if session.AccessToken == "" { 454 + return nil, fmt.Errorf("session has no access token") 455 + } 456 + if session.HostURL == "" { 457 + return nil, fmt.Errorf("session has no host URL") 458 + } 459 + 460 + return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken) 461 + } 462 + }
+2 -1
tests/integration/post_creation_test.go
··· 35 35 36 36 communityRepo := postgres.NewCommunityRepository(db) 37 37 // Note: Provisioner not needed for this test (we're not actually creating communities) 38 - communityService := communities.NewCommunityService( 38 + communityService := communities.NewCommunityServiceWithPDSFactory( 39 39 communityRepo, 40 40 "http://localhost:3001", 41 41 "did:web:test.coves.social", 42 42 "test.coves.social", 43 43 nil, // provisioner 44 + nil, // pdsClientFactory 44 45 ) 45 46 46 47 postRepo := postgres.NewPostRepository(db)
+2 -1
tests/integration/post_e2e_test.go
··· 394 394 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL) 395 395 396 396 // Setup community service with real PDS provisioner 397 - communityService := communities.NewCommunityService( 397 + communityService := communities.NewCommunityServiceWithPDSFactory( 398 398 communityRepo, 399 399 pdsURL, 400 400 instanceDID, 401 401 instanceDomain, 402 402 provisioner, // โœ… Real provisioner for creating communities on PDS 403 + nil, // No PDS factory needed - no subscribe/block in this test 403 404 ) 404 405 405 406 postService := posts.NewPostService(postRepo, communityService, nil, nil, nil, nil, pdsURL) // nil aggregatorService, blobService, unfurlService, blueskyService for user-only tests
+6 -3
tests/integration/post_handler_test.go
··· 32 32 33 33 // Setup services 34 34 communityRepo := postgres.NewCommunityRepository(db) 35 - communityService := communities.NewCommunityService( 35 + communityService := communities.NewCommunityServiceWithPDSFactory( 36 36 communityRepo, 37 37 "http://localhost:3001", 38 38 "did:web:test.coves.social", 39 39 "test.coves.social", 40 40 nil, 41 + nil, 41 42 ) 42 43 43 44 postRepo := postgres.NewPostRepository(db) ··· 400 401 401 402 // Setup services 402 403 communityRepo := postgres.NewCommunityRepository(db) 403 - communityService := communities.NewCommunityService( 404 + communityService := communities.NewCommunityServiceWithPDSFactory( 404 405 communityRepo, 405 406 "http://localhost:3001", 406 407 "did:web:test.coves.social", 407 408 "test.coves.social", 408 409 nil, 410 + nil, 409 411 ) 410 412 411 413 postRepo := postgres.NewPostRepository(db) ··· 484 486 485 487 // Setup services 486 488 communityRepo := postgres.NewCommunityRepository(db) 487 - communityService := communities.NewCommunityService( 489 + communityService := communities.NewCommunityServiceWithPDSFactory( 488 490 communityRepo, 489 491 "http://localhost:3001", 490 492 "did:web:test.coves.social", 491 493 "test.coves.social", 492 494 nil, 495 + nil, 493 496 ) 494 497 495 498 postRepo := postgres.NewPostRepository(db)
+2 -1
tests/integration/post_thumb_validation_test.go
··· 55 55 56 56 // Setup services 57 57 communityRepo := postgres.NewCommunityRepository(db) 58 - communityService := communities.NewCommunityService( 58 + communityService := communities.NewCommunityServiceWithPDSFactory( 59 59 communityRepo, 60 60 "http://localhost:3001", 61 61 "did:web:test.coves.social", 62 62 "test.coves.social", 63 63 nil, 64 + nil, 64 65 ) 65 66 66 67 postRepo := postgres.NewPostRepository(db)
+8 -4
tests/integration/post_unfurl_test.go
··· 51 51 unfurl.WithCacheTTL(24*time.Hour), 52 52 ) 53 53 54 - communityService := communities.NewCommunityService( 54 + communityService := communities.NewCommunityServiceWithPDSFactory( 55 55 communityRepo, 56 56 "http://localhost:3001", 57 57 "did:web:test.coves.social", 58 58 "test.coves.social", 59 59 nil, 60 + nil, 60 61 ) 61 62 62 63 postService := posts.NewPostService( ··· 348 349 identityResolver := identity.NewResolver(db, identityConfig) 349 350 userService := users.NewUserService(userRepo, identityResolver, "http://localhost:3001") 350 351 351 - communityService := communities.NewCommunityService( 352 + communityService := communities.NewCommunityServiceWithPDSFactory( 352 353 communityRepo, 353 354 "http://localhost:3001", 354 355 "did:web:test.coves.social", 355 356 "test.coves.social", 356 357 nil, 358 + nil, 357 359 ) 358 360 359 361 // Create post service WITHOUT unfurl service ··· 456 458 unfurl.WithCacheTTL(24*time.Hour), 457 459 ) 458 460 459 - communityService := communities.NewCommunityService( 461 + communityService := communities.NewCommunityServiceWithPDSFactory( 460 462 communityRepo, 461 463 "http://localhost:3001", 462 464 "did:web:test.coves.social", 463 465 "test.coves.social", 464 466 nil, 467 + nil, 465 468 ) 466 469 467 470 postService := posts.NewPostService( ··· 568 571 unfurl.WithTimeout(30*time.Second), 569 572 ) 570 573 571 - communityService := communities.NewCommunityService( 574 + communityService := communities.NewCommunityServiceWithPDSFactory( 572 575 communityRepo, 573 576 "http://localhost:3001", 574 577 "did:web:test.coves.social", 575 578 "test.coves.social", 576 579 nil, 580 + nil, 577 581 ) 578 582 579 583 postService := posts.NewPostService(
+520
internal/api/handlers/community/block_test.go
··· 1 + package community 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/communities" 6 + "bytes" 7 + "context" 8 + "encoding/json" 9 + "net/http" 10 + "net/http/httptest" 11 + "testing" 12 + "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + ) 17 + 18 + // blockTestService implements communities.Service for block handler tests 19 + type blockTestService struct { 20 + blockFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) 21 + unblockFunc func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error 22 + } 23 + 24 + func (m *blockTestService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) { 25 + return nil, nil 26 + } 27 + 28 + func (m *blockTestService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) { 29 + return nil, nil 30 + } 31 + 32 + func (m *blockTestService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) { 33 + return nil, nil 34 + } 35 + 36 + func (m *blockTestService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 37 + return nil, nil 38 + } 39 + 40 + func (m *blockTestService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 41 + return nil, 0, nil 42 + } 43 + 44 + func (m *blockTestService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 45 + return nil, nil 46 + } 47 + 48 + func (m *blockTestService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 49 + return nil 50 + } 51 + 52 + func (m *blockTestService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) { 53 + return nil, nil 54 + } 55 + 56 + func (m *blockTestService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) { 57 + return nil, nil 58 + } 59 + 60 + func (m *blockTestService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 61 + if m.blockFunc != nil { 62 + return m.blockFunc(ctx, session, communityIdentifier) 63 + } 64 + userDID := "" 65 + if session != nil { 66 + userDID = session.AccountDID.String() 67 + } 68 + return &communities.CommunityBlock{ 69 + UserDID: userDID, 70 + CommunityDID: "did:plc:community123", 71 + RecordURI: "at://did:plc:user/social.coves.community.block/abc123", 72 + RecordCID: "bafytest123", 73 + BlockedAt: time.Now(), 74 + }, nil 75 + } 76 + 77 + func (m *blockTestService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 78 + if m.unblockFunc != nil { 79 + return m.unblockFunc(ctx, session, communityIdentifier) 80 + } 81 + return nil 82 + } 83 + 84 + func (m *blockTestService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) { 85 + return nil, nil 86 + } 87 + 88 + func (m *blockTestService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) { 89 + return false, nil 90 + } 91 + 92 + func (m *blockTestService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) { 93 + return nil, nil 94 + } 95 + 96 + func (m *blockTestService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) { 97 + return nil, nil 98 + } 99 + 100 + func (m *blockTestService) ValidateHandle(handle string) error { 101 + return nil 102 + } 103 + 104 + func (m *blockTestService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) { 105 + return identifier, nil 106 + } 107 + 108 + func (m *blockTestService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) { 109 + return community, nil 110 + } 111 + 112 + func (m *blockTestService) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 113 + return nil, nil 114 + } 115 + 116 + // createBlockTestOAuthSession creates a mock OAuth session for block handler tests 117 + func createBlockTestOAuthSession(did string) *oauth.ClientSessionData { 118 + parsedDID, _ := syntax.ParseDID(did) 119 + return &oauth.ClientSessionData{ 120 + AccountDID: parsedDID, 121 + SessionID: "test-session", 122 + HostURL: "http://localhost:3001", 123 + AccessToken: "test-access-token", 124 + } 125 + } 126 + 127 + func TestBlockHandler_Block_Success(t *testing.T) { 128 + tests := []struct { 129 + name string 130 + community string 131 + expectedCommunity string 132 + }{ 133 + { 134 + name: "block with DID", 135 + community: "did:plc:community123", 136 + expectedCommunity: "did:plc:community123", 137 + }, 138 + { 139 + name: "block with canonical handle", 140 + community: "c-worldnews.coves.social", 141 + expectedCommunity: "c-worldnews.coves.social", 142 + }, 143 + { 144 + name: "block with scoped identifier", 145 + community: "!worldnews@coves.social", 146 + expectedCommunity: "!worldnews@coves.social", 147 + }, 148 + { 149 + name: "block with at-identifier", 150 + community: "@c-tech.coves.social", 151 + expectedCommunity: "@c-tech.coves.social", 152 + }, 153 + } 154 + 155 + for _, tc := range tests { 156 + t.Run(tc.name, func(t *testing.T) { 157 + var receivedIdentifier string 158 + mockService := &blockTestService{ 159 + blockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 160 + receivedIdentifier = communityIdentifier 161 + userDID := "" 162 + if session != nil { 163 + userDID = session.AccountDID.String() 164 + } 165 + return &communities.CommunityBlock{ 166 + UserDID: userDID, 167 + CommunityDID: "did:plc:resolved", 168 + RecordURI: "at://did:plc:user/social.coves.community.block/abc123", 169 + RecordCID: "bafytest123", 170 + BlockedAt: time.Now(), 171 + }, nil 172 + }, 173 + } 174 + 175 + handler := NewBlockHandler(mockService) 176 + 177 + reqBody := map[string]interface{}{ 178 + "community": tc.community, 179 + } 180 + bodyBytes, _ := json.Marshal(reqBody) 181 + 182 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes)) 183 + req.Header.Set("Content-Type", "application/json") 184 + 185 + // Inject OAuth session into context 186 + session := createBlockTestOAuthSession("did:plc:testuser") 187 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 188 + req = req.WithContext(ctx) 189 + 190 + w := httptest.NewRecorder() 191 + handler.HandleBlock(w, req) 192 + 193 + if w.Code != http.StatusOK { 194 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 195 + } 196 + 197 + // Verify the community identifier was passed through correctly 198 + if receivedIdentifier != tc.expectedCommunity { 199 + t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier) 200 + } 201 + 202 + // Verify response structure 203 + var resp struct { 204 + Block struct { 205 + RecordURI string `json:"recordUri"` 206 + RecordCID string `json:"recordCid"` 207 + } `json:"block"` 208 + } 209 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 210 + t.Fatalf("Failed to decode response: %v", err) 211 + } 212 + if resp.Block.RecordURI == "" || resp.Block.RecordCID == "" { 213 + t.Errorf("Expected recordUri and recordCid in response, got %+v", resp) 214 + } 215 + }) 216 + } 217 + } 218 + 219 + func TestBlockHandler_Block_RequiresOAuthSession(t *testing.T) { 220 + mockService := &blockTestService{} 221 + handler := NewBlockHandler(mockService) 222 + 223 + reqBody := map[string]interface{}{ 224 + "community": "did:plc:test", 225 + } 226 + bodyBytes, _ := json.Marshal(reqBody) 227 + 228 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes)) 229 + req.Header.Set("Content-Type", "application/json") 230 + 231 + // No OAuth session in context 232 + 233 + w := httptest.NewRecorder() 234 + handler.HandleBlock(w, req) 235 + 236 + if w.Code != http.StatusUnauthorized { 237 + t.Errorf("Expected status 401, got %d", w.Code) 238 + } 239 + 240 + var errResp struct { 241 + Error string `json:"error"` 242 + } 243 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 244 + t.Fatalf("Failed to decode error response: %v", err) 245 + } 246 + if errResp.Error != "AuthRequired" { 247 + t.Errorf("Expected error AuthRequired, got %s", errResp.Error) 248 + } 249 + } 250 + 251 + func TestBlockHandler_Block_RequiresCommunity(t *testing.T) { 252 + mockService := &blockTestService{} 253 + handler := NewBlockHandler(mockService) 254 + 255 + reqBody := map[string]interface{}{} 256 + bodyBytes, _ := json.Marshal(reqBody) 257 + 258 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes)) 259 + req.Header.Set("Content-Type", "application/json") 260 + 261 + session := createBlockTestOAuthSession("did:plc:testuser") 262 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 263 + req = req.WithContext(ctx) 264 + 265 + w := httptest.NewRecorder() 266 + handler.HandleBlock(w, req) 267 + 268 + if w.Code != http.StatusBadRequest { 269 + t.Errorf("Expected status 400, got %d", w.Code) 270 + } 271 + } 272 + 273 + func TestBlockHandler_Block_ServiceErrors(t *testing.T) { 274 + tests := []struct { 275 + name string 276 + serviceErr error 277 + expectedStatus int 278 + expectedError string 279 + }{ 280 + { 281 + name: "community not found", 282 + serviceErr: communities.ErrCommunityNotFound, 283 + expectedStatus: http.StatusNotFound, 284 + expectedError: "NotFound", 285 + }, 286 + { 287 + name: "validation error", 288 + serviceErr: communities.NewValidationError("community", "invalid format"), 289 + expectedStatus: http.StatusBadRequest, 290 + expectedError: "InvalidRequest", 291 + }, 292 + { 293 + name: "already blocked", 294 + serviceErr: communities.ErrBlockAlreadyExists, 295 + expectedStatus: http.StatusConflict, 296 + expectedError: "AlreadyExists", 297 + }, 298 + { 299 + name: "unauthorized", 300 + serviceErr: communities.ErrUnauthorized, 301 + expectedStatus: http.StatusForbidden, 302 + expectedError: "Forbidden", 303 + }, 304 + } 305 + 306 + for _, tc := range tests { 307 + t.Run(tc.name, func(t *testing.T) { 308 + mockService := &blockTestService{ 309 + blockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 310 + return nil, tc.serviceErr 311 + }, 312 + } 313 + 314 + handler := NewBlockHandler(mockService) 315 + 316 + reqBody := map[string]interface{}{ 317 + "community": "did:plc:test", 318 + } 319 + bodyBytes, _ := json.Marshal(reqBody) 320 + 321 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBuffer(bodyBytes)) 322 + req.Header.Set("Content-Type", "application/json") 323 + 324 + session := createBlockTestOAuthSession("did:plc:testuser") 325 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 326 + req = req.WithContext(ctx) 327 + 328 + w := httptest.NewRecorder() 329 + handler.HandleBlock(w, req) 330 + 331 + if w.Code != tc.expectedStatus { 332 + t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, w.Code, w.Body.String()) 333 + } 334 + 335 + var errResp struct { 336 + Error string `json:"error"` 337 + } 338 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 339 + t.Fatalf("Failed to decode error response: %v", err) 340 + } 341 + if errResp.Error != tc.expectedError { 342 + t.Errorf("Expected error %s, got %s", tc.expectedError, errResp.Error) 343 + } 344 + }) 345 + } 346 + } 347 + 348 + func TestBlockHandler_Unblock_Success(t *testing.T) { 349 + tests := []struct { 350 + name string 351 + community string 352 + expectedCommunity string 353 + }{ 354 + { 355 + name: "unblock with DID", 356 + community: "did:plc:community123", 357 + expectedCommunity: "did:plc:community123", 358 + }, 359 + { 360 + name: "unblock with canonical handle", 361 + community: "c-worldnews.coves.social", 362 + expectedCommunity: "c-worldnews.coves.social", 363 + }, 364 + { 365 + name: "unblock with scoped identifier", 366 + community: "!worldnews@coves.social", 367 + expectedCommunity: "!worldnews@coves.social", 368 + }, 369 + } 370 + 371 + for _, tc := range tests { 372 + t.Run(tc.name, func(t *testing.T) { 373 + var receivedIdentifier string 374 + mockService := &blockTestService{ 375 + unblockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 376 + receivedIdentifier = communityIdentifier 377 + return nil 378 + }, 379 + } 380 + 381 + handler := NewBlockHandler(mockService) 382 + 383 + reqBody := map[string]interface{}{ 384 + "community": tc.community, 385 + } 386 + bodyBytes, _ := json.Marshal(reqBody) 387 + 388 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes)) 389 + req.Header.Set("Content-Type", "application/json") 390 + 391 + session := createBlockTestOAuthSession("did:plc:testuser") 392 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 393 + req = req.WithContext(ctx) 394 + 395 + w := httptest.NewRecorder() 396 + handler.HandleUnblock(w, req) 397 + 398 + if w.Code != http.StatusOK { 399 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 400 + } 401 + 402 + if receivedIdentifier != tc.expectedCommunity { 403 + t.Errorf("Expected community %q to be passed to service, got %q", tc.expectedCommunity, receivedIdentifier) 404 + } 405 + 406 + var resp struct { 407 + Success bool `json:"success"` 408 + } 409 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 410 + t.Fatalf("Failed to decode response: %v", err) 411 + } 412 + if !resp.Success { 413 + t.Errorf("Expected success: true in response") 414 + } 415 + }) 416 + } 417 + } 418 + 419 + func TestBlockHandler_Unblock_RequiresOAuthSession(t *testing.T) { 420 + mockService := &blockTestService{} 421 + handler := NewBlockHandler(mockService) 422 + 423 + reqBody := map[string]interface{}{ 424 + "community": "did:plc:test", 425 + } 426 + bodyBytes, _ := json.Marshal(reqBody) 427 + 428 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes)) 429 + req.Header.Set("Content-Type", "application/json") 430 + 431 + // No OAuth session in context 432 + 433 + w := httptest.NewRecorder() 434 + handler.HandleUnblock(w, req) 435 + 436 + if w.Code != http.StatusUnauthorized { 437 + t.Errorf("Expected status 401, got %d", w.Code) 438 + } 439 + 440 + var errResp struct { 441 + Error string `json:"error"` 442 + } 443 + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { 444 + t.Fatalf("Failed to decode error response: %v", err) 445 + } 446 + if errResp.Error != "AuthRequired" { 447 + t.Errorf("Expected error AuthRequired, got %s", errResp.Error) 448 + } 449 + } 450 + 451 + func TestBlockHandler_Unblock_BlockNotFound(t *testing.T) { 452 + mockService := &blockTestService{ 453 + unblockFunc: func(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 454 + return communities.ErrBlockNotFound 455 + }, 456 + } 457 + 458 + handler := NewBlockHandler(mockService) 459 + 460 + reqBody := map[string]interface{}{ 461 + "community": "did:plc:test", 462 + } 463 + bodyBytes, _ := json.Marshal(reqBody) 464 + 465 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.unblockCommunity", bytes.NewBuffer(bodyBytes)) 466 + req.Header.Set("Content-Type", "application/json") 467 + 468 + session := createBlockTestOAuthSession("did:plc:testuser") 469 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 470 + req = req.WithContext(ctx) 471 + 472 + w := httptest.NewRecorder() 473 + handler.HandleUnblock(w, req) 474 + 475 + if w.Code != http.StatusNotFound { 476 + t.Errorf("Expected status 404, got %d. Body: %s", w.Code, w.Body.String()) 477 + } 478 + } 479 + 480 + func TestBlockHandler_MethodNotAllowed(t *testing.T) { 481 + mockService := &blockTestService{} 482 + handler := NewBlockHandler(mockService) 483 + 484 + // Test GET on block endpoint 485 + req := httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.blockCommunity", nil) 486 + w := httptest.NewRecorder() 487 + handler.HandleBlock(w, req) 488 + 489 + if w.Code != http.StatusMethodNotAllowed { 490 + t.Errorf("Expected status 405, got %d", w.Code) 491 + } 492 + 493 + // Test GET on unblock endpoint 494 + req = httptest.NewRequest(http.MethodGet, "/xrpc/social.coves.community.unblockCommunity", nil) 495 + w = httptest.NewRecorder() 496 + handler.HandleUnblock(w, req) 497 + 498 + if w.Code != http.StatusMethodNotAllowed { 499 + t.Errorf("Expected status 405, got %d", w.Code) 500 + } 501 + } 502 + 503 + func TestBlockHandler_InvalidJSON(t *testing.T) { 504 + mockService := &blockTestService{} 505 + handler := NewBlockHandler(mockService) 506 + 507 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.community.blockCommunity", bytes.NewBufferString("invalid json")) 508 + req.Header.Set("Content-Type", "application/json") 509 + 510 + session := createBlockTestOAuthSession("did:plc:testuser") 511 + ctx := context.WithValue(req.Context(), middleware.OAuthSessionKey, session) 512 + req = req.WithContext(ctx) 513 + 514 + w := httptest.NewRecorder() 515 + handler.HandleBlock(w, req) 516 + 517 + if w.Code != http.StatusBadRequest { 518 + t.Errorf("Expected status 400, got %d", w.Code) 519 + } 520 + }
+12 -1
internal/api/handlers/community/errors.go
··· 1 1 package community 2 2 3 3 import ( 4 + "Coves/internal/atproto/pds" 4 5 "Coves/internal/core/communities" 5 6 "encoding/json" 7 + "errors" 6 8 "log" 7 9 "net/http" 8 10 ) ··· 42 44 writeError(w, http.StatusForbidden, "Forbidden", "You do not have permission to perform this action") 43 45 case err == communities.ErrMemberBanned: 44 46 writeError(w, http.StatusForbidden, "Blocked", "You are blocked from this community") 47 + // PDS-specific errors (from DPoP authentication or PDS API calls) 48 + case errors.Is(err, pds.ErrBadRequest): 49 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request to PDS") 50 + case errors.Is(err, pds.ErrNotFound): 51 + writeError(w, http.StatusNotFound, "NotFound", "Record not found on PDS") 52 + case errors.Is(err, pds.ErrConflict): 53 + writeError(w, http.StatusConflict, "Conflict", "Record was modified by another operation") 54 + case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden): 55 + // PDS auth errors should prompt re-authentication 56 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required or session expired") 45 57 default: 46 58 // Internal server error - log the actual error for debugging 47 - // TODO: Use proper logger instead of log package 48 59 log.Printf("XRPC handler error: %v", err) 49 60 writeError(w, http.StatusInternalServerError, "InternalServerError", "An internal error occurred") 50 61 }
+9 -2
internal/atproto/pds/factory.go
··· 24 24 } 25 25 26 26 // ResumeSession reconstructs the OAuth session with DPoP key 27 - // and returns a ClientSession that can generate authenticated requests 27 + // and returns a ClientSession that can generate authenticated requests. 28 + // Common failure modes: 29 + // - Expired access/refresh tokens โ†’ User needs to re-authenticate 30 + // - Session revoked on PDS โ†’ User needs to re-authenticate 31 + // - DPoP nonce mismatch โ†’ Retry may help (transient) 32 + // - DPoP key mismatch โ†’ Session data corrupted, re-authenticate 28 33 sess, err := oauthClient.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID) 29 34 if err != nil { 30 - return nil, fmt.Errorf("failed to resume OAuth session: %w", err) 35 + // Include DID and session context for debugging 36 + return nil, fmt.Errorf("failed to resume OAuth session for DID=%s, sessionID=%s: %w", 37 + sessionData.AccountDID.String(), sessionData.SessionID, err) 31 38 } 32 39 33 40 // APIClient() returns an *atclient.APIClient configured with DPoP auth
+7 -23
internal/core/communities/service.go
··· 40 40 repo Repository 41 41 provisioner *PDSAccountProvisioner 42 42 43 - // OAuth client/store for user PDS authentication (DPoP-based) 43 + // OAuth client for user PDS authentication (DPoP-based) 44 44 oauthClient *oauthclient.OAuthClient 45 - oauthStore oauth.ClientAuthStore 46 45 pdsClientFactory PDSClientFactory // Optional, for testing. If nil, uses OAuth. 47 46 48 47 // Token refresh concurrency control ··· 72 71 pdsURL, instanceDID, instanceDomain string, 73 72 provisioner *PDSAccountProvisioner, 74 73 oauthClient *oauthclient.OAuthClient, 75 - oauthStore oauth.ClientAuthStore, 76 74 ) Service { 77 75 // SECURITY: Basic validation that did:web domain matches configured instanceDomain 78 76 // This catches honest configuration mistakes but NOT malicious code modifications ··· 95 93 instanceDomain: instanceDomain, 96 94 provisioner: provisioner, 97 95 oauthClient: oauthClient, 98 - oauthStore: oauthStore, 99 96 refreshMutexes: make(map[string]*sync.Mutex), 100 97 } 101 98 } ··· 136 133 137 134 // Production path: use OAuth with DPoP 138 135 if s.oauthClient == nil || s.oauthClient.ClientApp == nil { 136 + log.Printf("[OAUTH_ERROR] getPDSClient called but OAuth client is not configured - check server initialization") 139 137 return nil, fmt.Errorf("OAuth client not configured") 140 138 } 141 139 ··· 664 662 // Resolve community identifier to DID 665 663 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 666 664 if err != nil { 667 - return nil, err 665 + return nil, fmt.Errorf("subscribe: %w", err) 668 666 } 669 667 670 668 // Verify community exists ··· 732 730 // Resolve community identifier 733 731 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 734 732 if err != nil { 735 - return err 733 + return fmt.Errorf("unsubscribe: %w", err) 736 734 } 737 735 738 736 // Get the subscription from AppView to find the record key ··· 824 822 // Resolve community identifier (also verifies community exists) 825 823 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 826 824 if err != nil { 827 - return nil, err 825 + return nil, fmt.Errorf("block: %w", err) 828 826 } 829 827 830 828 // Create PDS client for this session (DPoP authentication) 831 829 pdsClient, err := s.getPDSClient(ctx, session) 832 830 if err != nil { 833 - return nil, fmt.Errorf("failed to create PDS client: %w", err) 831 + return nil, fmt.Errorf("block: failed to create PDS client: %w", err) 834 832 } 835 833 836 834 // Generate TID for record key ··· 904 902 // Resolve community identifier 905 903 communityDID, err := s.ResolveCommunityIdentifier(ctx, communityIdentifier) 906 904 if err != nil { 907 - return err 905 + return fmt.Errorf("unblock: %w", err) 908 906 } 909 907 910 908 // Get the block from AppView to find the record key ··· 1180 1178 return s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 1181 1179 } 1182 1180 1183 - // deleteRecordOnPDSAs deletes a record with a specific access token (for user-scoped deletions) 1184 - func (s *communityService) deleteRecordOnPDSAs(ctx context.Context, repoDID, collection, rkey, accessToken string) error { 1185 - endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord", strings.TrimSuffix(s.pdsURL, "/")) 1186 - 1187 - payload := map[string]interface{}{ 1188 - "repo": repoDID, 1189 - "collection": collection, 1190 - "rkey": rkey, 1191 - } 1192 - 1193 - _, _, err := s.callPDSWithAuth(ctx, "POST", endpoint, payload, accessToken) 1194 - return err 1195 - } 1196 - 1197 1181 // callPDSWithAuth makes a PDS call with a specific access token (V2: for community authentication) 1198 1182 func (s *communityService) callPDSWithAuth(ctx context.Context, method, endpoint string, payload map[string]interface{}, accessToken string) (string, string, error) { 1199 1183 jsonData, err := json.Marshal(payload)
+1 -1
cmd/server/main.go
··· 606 606 607 607 // Register XRPC routes 608 608 routes.RegisterUserRoutes(r, userService) 609 - routes.RegisterCommunityRoutes(r, communityService, authMiddleware, allowedCommunityCreators) 609 + routes.RegisterCommunityRoutes(r, communityService, communityRepo, authMiddleware, allowedCommunityCreators) 610 610 log.Println("Community XRPC endpoints registered with OAuth authentication") 611 611 612 612 routes.RegisterPostRoutes(r, postService, dualAuth)
+41
internal/api/handlers/common/viewer_state.go
··· 2 2 3 3 import ( 4 4 "Coves/internal/api/middleware" 5 + "Coves/internal/core/communities" 5 6 "Coves/internal/core/posts" 6 7 "Coves/internal/core/votes" 7 8 "context" ··· 71 72 } 72 73 } 73 74 } 75 + 76 + // PopulateCommunityViewerState enriches communities with the authenticated user's subscription state. 77 + // This is a no-op if the request is unauthenticated. 78 + func PopulateCommunityViewerState( 79 + ctx context.Context, 80 + r *http.Request, 81 + repo communities.Repository, 82 + communityList []*communities.Community, 83 + ) { 84 + if repo == nil || len(communityList) == 0 { 85 + return 86 + } 87 + 88 + userDID := middleware.GetUserDID(r) 89 + if userDID == "" { 90 + return // Not authenticated, leave viewer state nil 91 + } 92 + 93 + // Collect community DIDs 94 + communityDIDs := make([]string, len(communityList)) 95 + for i, c := range communityList { 96 + communityDIDs[i] = c.DID 97 + } 98 + 99 + // Batch query subscriptions 100 + subscribed, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, communityDIDs) 101 + if err != nil { 102 + log.Printf("Warning: failed to get subscription state for user %s (%d communities): %v", 103 + userDID, len(communityDIDs), err) 104 + return 105 + } 106 + 107 + // Populate viewer state on each community 108 + for _, c := range communityList { 109 + isSubscribed := subscribed[c.DID] 110 + c.Viewer = &communities.CommunityViewerState{ 111 + Subscribed: &isSubscribed, 112 + } 113 + } 114 + }
+7 -1
internal/api/handlers/community/list.go
··· 1 1 package community 2 2 3 3 import ( 4 + "Coves/internal/api/handlers/common" 4 5 "Coves/internal/core/communities" 5 6 "encoding/json" 6 7 "net/http" ··· 10 11 // ListHandler handles listing communities 11 12 type ListHandler struct { 12 13 service communities.Service 14 + repo communities.Repository 13 15 } 14 16 15 17 // NewListHandler creates a new list handler 16 - func NewListHandler(service communities.Service) *ListHandler { 18 + func NewListHandler(service communities.Service, repo communities.Repository) *ListHandler { 17 19 return &ListHandler{ 18 20 service: service, 21 + repo: repo, 19 22 } 20 23 } 21 24 ··· 100 103 return 101 104 } 102 105 106 + // Populate viewer state if authenticated 107 + common.PopulateCommunityViewerState(r.Context(), r, h.repo, results) 108 + 103 109 // Build response 104 110 var cursor string 105 111 if len(results) == limit {
+15 -2
internal/core/communities/community.go
··· 41 41 PostCount int `json:"postCount" db:"post_count"` 42 42 SubscriberCount int `json:"subscriberCount" db:"subscriber_count"` 43 43 MemberCount int `json:"memberCount" db:"member_count"` 44 - ID int `json:"id" db:"id"` 45 - AllowExternalDiscovery bool `json:"allowExternalDiscovery" db:"allow_external_discovery"` 44 + ID int `json:"id" db:"id"` 45 + AllowExternalDiscovery bool `json:"allowExternalDiscovery" db:"allow_external_discovery"` 46 + Viewer *CommunityViewerState `json:"viewer,omitempty" db:"-"` 47 + } 48 + 49 + // CommunityViewerState contains viewer-specific state for community list views. 50 + // This is a simplified version - detailed views use the full viewerState from lexicon. 51 + // 52 + // Fields use *bool to represent three states: 53 + // - nil: State not queried (unauthenticated request) 54 + // - true: User has this relationship 55 + // - false: User does not have this relationship 56 + type CommunityViewerState struct { 57 + Subscribed *bool `json:"subscribed,omitempty"` 58 + Member *bool `json:"member,omitempty"` 46 59 } 47 60 48 61 // Subscription represents a lightweight feed follow (user subscribes to see posts)
+48
internal/db/postgres/community_repo_subscriptions.go
··· 344 344 345 345 return result, nil 346 346 } 347 + 348 + // GetSubscribedCommunityDIDs returns a map of community DIDs that the user is subscribed to 349 + // This is optimized for batch lookups when populating viewer state 350 + func (r *postgresCommunityRepo) GetSubscribedCommunityDIDs(ctx context.Context, userDID string, communityDIDs []string) (map[string]bool, error) { 351 + if len(communityDIDs) == 0 { 352 + return map[string]bool{}, nil 353 + } 354 + 355 + // Build query with placeholders for IN clause 356 + placeholders := make([]string, len(communityDIDs)) 357 + args := make([]interface{}, len(communityDIDs)+1) 358 + args[0] = userDID 359 + for i, did := range communityDIDs { 360 + placeholders[i] = fmt.Sprintf("$%d", i+2) 361 + args[i+1] = did 362 + } 363 + 364 + query := fmt.Sprintf(` 365 + SELECT community_did 366 + FROM community_subscriptions 367 + WHERE user_did = $1 AND community_did IN (%s)`, 368 + strings.Join(placeholders, ", ")) 369 + 370 + rows, err := r.db.QueryContext(ctx, query, args...) 371 + if err != nil { 372 + return nil, fmt.Errorf("failed to get subscribed communities: %w", err) 373 + } 374 + defer func() { 375 + if closeErr := rows.Close(); closeErr != nil { 376 + log.Printf("Failed to close rows: %v", closeErr) 377 + } 378 + }() 379 + 380 + result := make(map[string]bool) 381 + for rows.Next() { 382 + var communityDID string 383 + if err := rows.Scan(&communityDID); err != nil { 384 + return nil, fmt.Errorf("failed to scan community DID: %w", err) 385 + } 386 + result[communityDID] = true 387 + } 388 + 389 + if err = rows.Err(); err != nil { 390 + return nil, fmt.Errorf("error iterating subscribed communities: %w", err) 391 + } 392 + 393 + return result, nil 394 + }
+1 -1
tests/integration/community_e2e_test.go
··· 164 164 165 165 // Setup HTTP server with XRPC routes 166 166 r := chi.NewRouter() 167 - routes.RegisterCommunityRoutes(r, communityService, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators 167 + routes.RegisterCommunityRoutes(r, communityService, communityRepo, e2eAuth.OAuthAuthMiddleware, nil) // nil = allow all community creators 168 168 httpServer := httptest.NewServer(r) 169 169 defer httpServer.Close() 170 170
+268
tests/integration/community_list_viewer_state_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "Coves/internal/api/handlers/community" 5 + "Coves/internal/api/middleware" 6 + "Coves/internal/core/communities" 7 + "Coves/internal/db/postgres" 8 + "context" 9 + "encoding/json" 10 + "fmt" 11 + "net/http" 12 + "net/http/httptest" 13 + "testing" 14 + "time" 15 + 16 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 + "github.com/go-chi/chi/v5" 18 + ) 19 + 20 + // TestCommunityList_ViewerState tests that the list communities endpoint 21 + // correctly populates viewer.subscribed field for authenticated users 22 + func TestCommunityList_ViewerState(t *testing.T) { 23 + db := setupTestDB(t) 24 + defer func() { 25 + if err := db.Close(); err != nil { 26 + t.Logf("Failed to close database: %v", err) 27 + } 28 + }() 29 + 30 + repo := postgres.NewCommunityRepository(db) 31 + ctx := context.Background() 32 + 33 + // Create test communities 34 + baseSuffix := time.Now().UnixNano() 35 + communityDIDs := make([]string, 3) 36 + for i := 0; i < 3; i++ { 37 + uniqueSuffix := fmt.Sprintf("%d%d", baseSuffix, i) 38 + communityDID := generateTestDID(uniqueSuffix) 39 + communityDIDs[i] = communityDID 40 + comm := &communities.Community{ 41 + DID: communityDID, 42 + Handle: fmt.Sprintf("c-viewer-test-%d-%d.coves.local", baseSuffix, i), 43 + Name: fmt.Sprintf("viewer-test-%d", i), 44 + DisplayName: fmt.Sprintf("Viewer Test Community %d", i), 45 + OwnerDID: "did:web:coves.local", 46 + CreatedByDID: "did:plc:testcreator", 47 + HostedByDID: "did:web:coves.local", 48 + Visibility: "public", 49 + CreatedAt: time.Now(), 50 + UpdatedAt: time.Now(), 51 + } 52 + if _, err := repo.Create(ctx, comm); err != nil { 53 + t.Fatalf("Failed to create community %d: %v", i, err) 54 + } 55 + } 56 + 57 + // Create a test user and subscribe them to community 0 and 2 58 + testUserDID := fmt.Sprintf("did:plc:viewertestuser%d", baseSuffix) 59 + 60 + sub1 := &communities.Subscription{ 61 + UserDID: testUserDID, 62 + CommunityDID: communityDIDs[0], 63 + ContentVisibility: 3, 64 + SubscribedAt: time.Now(), 65 + } 66 + if _, err := repo.Subscribe(ctx, sub1); err != nil { 67 + t.Fatalf("Failed to subscribe to community 0: %v", err) 68 + } 69 + 70 + sub2 := &communities.Subscription{ 71 + UserDID: testUserDID, 72 + CommunityDID: communityDIDs[2], 73 + ContentVisibility: 3, 74 + SubscribedAt: time.Now(), 75 + } 76 + if _, err := repo.Subscribe(ctx, sub2); err != nil { 77 + t.Fatalf("Failed to subscribe to community 2: %v", err) 78 + } 79 + 80 + // Create mock service that returns our communities 81 + mockService := &mockCommunityService{ 82 + repo: repo, 83 + } 84 + 85 + // Create handler with real repo for viewer state population 86 + listHandler := community.NewListHandler(mockService, repo) 87 + 88 + t.Run("authenticated user sees viewer.subscribed correctly", func(t *testing.T) { 89 + // Setup router with middleware that injects user DID 90 + r := chi.NewRouter() 91 + 92 + // Use test middleware that sets user DID in context 93 + r.Use(func(next http.Handler) http.Handler { 94 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 95 + ctx := middleware.SetTestUserDID(req.Context(), testUserDID) 96 + next.ServeHTTP(w, req.WithContext(ctx)) 97 + }) 98 + }) 99 + r.Get("/xrpc/social.coves.community.list", listHandler.HandleList) 100 + 101 + req := httptest.NewRequest("GET", "/xrpc/social.coves.community.list?limit=50", nil) 102 + rec := httptest.NewRecorder() 103 + 104 + r.ServeHTTP(rec, req) 105 + 106 + if rec.Code != http.StatusOK { 107 + t.Fatalf("Expected status 200, got %d: %s", rec.Code, rec.Body.String()) 108 + } 109 + 110 + var response struct { 111 + Communities []struct { 112 + DID string `json:"did"` 113 + Viewer *struct { 114 + Subscribed *bool `json:"subscribed"` 115 + } `json:"viewer"` 116 + } `json:"communities"` 117 + } 118 + 119 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 120 + t.Fatalf("Failed to decode response: %v", err) 121 + } 122 + 123 + // Check that viewer state is populated correctly 124 + subscriptionMap := map[string]bool{ 125 + communityDIDs[0]: true, 126 + communityDIDs[1]: false, 127 + communityDIDs[2]: true, 128 + } 129 + 130 + for _, comm := range response.Communities { 131 + expectedSubscribed, inTestSet := subscriptionMap[comm.DID] 132 + if !inTestSet { 133 + continue // Skip communities not in our test set 134 + } 135 + 136 + if comm.Viewer == nil { 137 + t.Errorf("Community %s has nil Viewer, expected populated", comm.DID) 138 + continue 139 + } 140 + 141 + if comm.Viewer.Subscribed == nil { 142 + t.Errorf("Community %s has nil Viewer.Subscribed, expected populated", comm.DID) 143 + continue 144 + } 145 + 146 + if *comm.Viewer.Subscribed != expectedSubscribed { 147 + t.Errorf("Community %s: expected subscribed=%v, got %v", 148 + comm.DID, expectedSubscribed, *comm.Viewer.Subscribed) 149 + } 150 + } 151 + }) 152 + 153 + t.Run("unauthenticated request has nil viewer state", func(t *testing.T) { 154 + // Setup router WITHOUT middleware that sets user DID 155 + r := chi.NewRouter() 156 + r.Get("/xrpc/social.coves.community.list", listHandler.HandleList) 157 + 158 + req := httptest.NewRequest("GET", "/xrpc/social.coves.community.list?limit=50", nil) 159 + rec := httptest.NewRecorder() 160 + 161 + r.ServeHTTP(rec, req) 162 + 163 + if rec.Code != http.StatusOK { 164 + t.Fatalf("Expected status 200, got %d: %s", rec.Code, rec.Body.String()) 165 + } 166 + 167 + var response struct { 168 + Communities []struct { 169 + DID string `json:"did"` 170 + Viewer *struct { 171 + Subscribed *bool `json:"subscribed"` 172 + } `json:"viewer"` 173 + } `json:"communities"` 174 + } 175 + 176 + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { 177 + t.Fatalf("Failed to decode response: %v", err) 178 + } 179 + 180 + // For unauthenticated requests, viewer should be nil for all communities 181 + for _, comm := range response.Communities { 182 + if comm.Viewer != nil { 183 + t.Errorf("Community %s has non-nil Viewer for unauthenticated request", comm.DID) 184 + } 185 + } 186 + }) 187 + } 188 + 189 + // mockCommunityService implements communities.Service for testing 190 + type mockCommunityService struct { 191 + repo communities.Repository 192 + } 193 + 194 + func (m *mockCommunityService) CreateCommunity(ctx context.Context, req communities.CreateCommunityRequest) (*communities.Community, error) { 195 + return nil, fmt.Errorf("not implemented") 196 + } 197 + 198 + func (m *mockCommunityService) GetCommunity(ctx context.Context, identifier string) (*communities.Community, error) { 199 + return nil, fmt.Errorf("not implemented") 200 + } 201 + 202 + func (m *mockCommunityService) UpdateCommunity(ctx context.Context, req communities.UpdateCommunityRequest) (*communities.Community, error) { 203 + return nil, fmt.Errorf("not implemented") 204 + } 205 + 206 + func (m *mockCommunityService) ListCommunities(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, error) { 207 + return m.repo.List(ctx, req) 208 + } 209 + 210 + func (m *mockCommunityService) SearchCommunities(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 211 + return nil, 0, fmt.Errorf("not implemented") 212 + } 213 + 214 + func (m *mockCommunityService) SubscribeToCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string, contentVisibility int) (*communities.Subscription, error) { 215 + return nil, fmt.Errorf("not implemented") 216 + } 217 + 218 + func (m *mockCommunityService) UnsubscribeFromCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 219 + return fmt.Errorf("not implemented") 220 + } 221 + 222 + func (m *mockCommunityService) GetUserSubscriptions(ctx context.Context, userDID string, limit, offset int) ([]*communities.Subscription, error) { 223 + return nil, fmt.Errorf("not implemented") 224 + } 225 + 226 + func (m *mockCommunityService) GetCommunitySubscribers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Subscription, error) { 227 + return nil, fmt.Errorf("not implemented") 228 + } 229 + 230 + func (m *mockCommunityService) BlockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) (*communities.CommunityBlock, error) { 231 + return nil, fmt.Errorf("not implemented") 232 + } 233 + 234 + func (m *mockCommunityService) UnblockCommunity(ctx context.Context, session *oauth.ClientSessionData, communityIdentifier string) error { 235 + return fmt.Errorf("not implemented") 236 + } 237 + 238 + func (m *mockCommunityService) GetBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) { 239 + return nil, fmt.Errorf("not implemented") 240 + } 241 + 242 + func (m *mockCommunityService) IsBlocked(ctx context.Context, userDID, communityIdentifier string) (bool, error) { 243 + return false, fmt.Errorf("not implemented") 244 + } 245 + 246 + func (m *mockCommunityService) GetMembership(ctx context.Context, userDID, communityIdentifier string) (*communities.Membership, error) { 247 + return nil, fmt.Errorf("not implemented") 248 + } 249 + 250 + func (m *mockCommunityService) ListCommunityMembers(ctx context.Context, communityIdentifier string, limit, offset int) ([]*communities.Membership, error) { 251 + return nil, fmt.Errorf("not implemented") 252 + } 253 + 254 + func (m *mockCommunityService) ValidateHandle(handle string) error { 255 + return nil 256 + } 257 + 258 + func (m *mockCommunityService) ResolveCommunityIdentifier(ctx context.Context, identifier string) (string, error) { 259 + return identifier, nil 260 + } 261 + 262 + func (m *mockCommunityService) EnsureFreshToken(ctx context.Context, community *communities.Community) (*communities.Community, error) { 263 + return community, nil 264 + } 265 + 266 + func (m *mockCommunityService) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 267 + return m.repo.GetByDID(ctx, did) 268 + }
+117
tests/integration/community_repo_test.go
··· 409 409 }) 410 410 } 411 411 412 + func TestCommunityRepository_GetSubscribedCommunityDIDs(t *testing.T) { 413 + db := setupTestDB(t) 414 + defer func() { 415 + if err := db.Close(); err != nil { 416 + t.Logf("Failed to close database: %v", err) 417 + } 418 + }() 419 + 420 + repo := postgres.NewCommunityRepository(db) 421 + ctx := context.Background() 422 + 423 + // Create test communities 424 + baseSuffix := time.Now().UnixNano() 425 + communityDIDs := make([]string, 3) 426 + for i := 0; i < 3; i++ { 427 + uniqueSuffix := fmt.Sprintf("%d%d", baseSuffix, i) 428 + communityDID := generateTestDID(uniqueSuffix) 429 + communityDIDs[i] = communityDID 430 + community := &communities.Community{ 431 + DID: communityDID, 432 + Handle: fmt.Sprintf("!batch-sub-test-%d-%d@coves.local", baseSuffix, i), 433 + Name: fmt.Sprintf("batch-sub-test-%d", i), 434 + OwnerDID: "did:web:coves.local", 435 + CreatedByDID: "did:plc:user123", 436 + HostedByDID: "did:web:coves.local", 437 + Visibility: "public", 438 + CreatedAt: time.Now(), 439 + UpdatedAt: time.Now(), 440 + } 441 + if _, err := repo.Create(ctx, community); err != nil { 442 + t.Fatalf("Failed to create community %d: %v", i, err) 443 + } 444 + } 445 + 446 + userDID := fmt.Sprintf("did:plc:batchsubuser%d", baseSuffix) 447 + 448 + t.Run("returns empty map when user has no subscriptions", func(t *testing.T) { 449 + result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, communityDIDs) 450 + if err != nil { 451 + t.Fatalf("Failed to get subscribed community DIDs: %v", err) 452 + } 453 + 454 + if len(result) != 0 { 455 + t.Errorf("Expected empty map, got %d entries", len(result)) 456 + } 457 + }) 458 + 459 + t.Run("returns subscribed communities only", func(t *testing.T) { 460 + // Subscribe to first and third community 461 + sub1 := &communities.Subscription{ 462 + UserDID: userDID, 463 + CommunityDID: communityDIDs[0], 464 + ContentVisibility: 3, 465 + SubscribedAt: time.Now(), 466 + } 467 + if _, err := repo.Subscribe(ctx, sub1); err != nil { 468 + t.Fatalf("Failed to subscribe to community 0: %v", err) 469 + } 470 + 471 + sub3 := &communities.Subscription{ 472 + UserDID: userDID, 473 + CommunityDID: communityDIDs[2], 474 + ContentVisibility: 3, 475 + SubscribedAt: time.Now(), 476 + } 477 + if _, err := repo.Subscribe(ctx, sub3); err != nil { 478 + t.Fatalf("Failed to subscribe to community 2: %v", err) 479 + } 480 + 481 + result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, communityDIDs) 482 + if err != nil { 483 + t.Fatalf("Failed to get subscribed community DIDs: %v", err) 484 + } 485 + 486 + if len(result) != 2 { 487 + t.Errorf("Expected 2 subscribed communities, got %d", len(result)) 488 + } 489 + 490 + if !result[communityDIDs[0]] { 491 + t.Errorf("Expected community 0 to be subscribed") 492 + } 493 + if result[communityDIDs[1]] { 494 + t.Errorf("Expected community 1 to NOT be subscribed") 495 + } 496 + if !result[communityDIDs[2]] { 497 + t.Errorf("Expected community 2 to be subscribed") 498 + } 499 + }) 500 + 501 + t.Run("returns empty map for empty community DIDs slice", func(t *testing.T) { 502 + result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, []string{}) 503 + if err != nil { 504 + t.Fatalf("Failed to get subscribed community DIDs: %v", err) 505 + } 506 + 507 + if len(result) != 0 { 508 + t.Errorf("Expected empty map for empty input, got %d entries", len(result)) 509 + } 510 + }) 511 + 512 + t.Run("handles non-existent community DIDs gracefully", func(t *testing.T) { 513 + nonExistentDIDs := []string{ 514 + "did:plc:nonexistent1", 515 + "did:plc:nonexistent2", 516 + } 517 + 518 + result, err := repo.GetSubscribedCommunityDIDs(ctx, userDID, nonExistentDIDs) 519 + if err != nil { 520 + t.Fatalf("Failed to get subscribed community DIDs: %v", err) 521 + } 522 + 523 + if len(result) != 0 { 524 + t.Errorf("Expected empty map for non-existent DIDs, got %d entries", len(result)) 525 + } 526 + }) 527 + } 528 + 412 529 // TODO: Implement search functionality before re-enabling this test 413 530 // func TestCommunityRepository_Search(t *testing.T) { 414 531 // db := setupTestDB(t)
+2 -1
internal/api/routes/oauth.go
··· 27 27 logoutLimiter := middleware.NewRateLimiter(10, 1*time.Minute) 28 28 29 29 // OAuth metadata endpoints - public, no extra rate limiting (use global limit) 30 - r.Get("/oauth/client-metadata.json", handler.HandleClientMetadata) 30 + // Serve at root /oauth-client-metadata.json so OAuth screens show clean brand domain 31 + r.Get("/oauth-client-metadata.json", handler.HandleClientMetadata) 31 32 r.Get("/.well-known/oauth-protected-resource", handler.HandleProtectedResourceMetadata) 32 33 33 34 // OAuth flow endpoints - stricter rate limiting for authentication attempts
+1 -1
internal/atproto/oauth/client.go
··· 90 90 } else { 91 91 // Production mode: public OAuth client with HTTPS 92 92 // client_id must be the URL of the client metadata document per atproto OAuth spec 93 - clientID := config.PublicURL + "/oauth/client-metadata.json" 93 + clientID := config.PublicURL + "/oauth-client-metadata.json" 94 94 callbackURL := config.PublicURL + "/oauth/callback" 95 95 clientConfig = oauth.NewPublicConfig(clientID, callbackURL, config.Scopes) 96 96 }
+1 -1
internal/atproto/oauth/handlers_test.go
··· 50 50 51 51 // Validate metadata 52 52 // Per atproto OAuth spec, client_id for public clients is the client metadata URL 53 - assert.Equal(t, "https://coves.social/oauth/client-metadata.json", metadata.ClientID) 53 + assert.Equal(t, "https://coves.social/oauth-client-metadata.json", metadata.ClientID) 54 54 assert.Contains(t, metadata.RedirectURIs, "https://coves.social/oauth/callback") 55 55 assert.Contains(t, metadata.GrantTypes, "authorization_code") 56 56 assert.Contains(t, metadata.GrantTypes, "refresh_token")
+16 -2
tests/integration/user_journey_e2e_test.go
··· 12 12 "context" 13 13 "database/sql" 14 14 "encoding/json" 15 + "errors" 15 16 "fmt" 17 + "io" 16 18 "net" 17 19 "net/http" 18 20 "net/http/httptest" ··· 775 777 var event jetstream.JetstreamEvent 776 778 err := conn.ReadJSON(&event) 777 779 if err != nil { 778 - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { 780 + // Handle close errors - connection is done 781 + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 779 782 return nil 780 783 } 781 - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 784 + 785 + // Handle EOF - connection was closed by server 786 + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { 787 + return nil 788 + } 789 + 790 + // Handle timeout errors using errors.As for wrapped errors 791 + var netErr net.Error 792 + if errors.As(err, &netErr) && netErr.Timeout() { 782 793 continue 783 794 } 795 + 796 + // For any other error, return immediately to avoid re-reading from failed connection 797 + // The gorilla/websocket library panics on repeated reads after a connection failure 784 798 return fmt.Errorf("failed to read Jetstream message: %w", err) 785 799 } 786 800