+79
internal/core/blueskypost/fetcher_test.go
+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
+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
+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
+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
+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
+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
+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
+
}
+77
internal/db/migrations/024_add_aggregator_api_keys.sql
+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
+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
+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
+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
+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
+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
+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
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
-1
aggregators/kagi-news/requirements.txt
+133
-55
aggregators/kagi-news/src/coves_client.py
+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
+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
+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
+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
+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
+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
+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
+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
+6
-6
internal/core/blobs/service.go
+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
-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
-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
+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
-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
+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
+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
+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
+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
+
}
+6
internal/core/posts/errors.go
+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
+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
+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
+
}
+285
internal/db/postgres/post_repo.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}
+9
internal/core/comments/comment.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+21
-5
tests/integration/block_handle_resolution_test.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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