diff --git a/internal/api/handlers/aggregator/get_authorizations.go b/internal/api/handlers/aggregator/get_authorizations.go index 13a5c17..85b2171 100644 --- a/internal/api/handlers/aggregator/get_authorizations.go +++ b/internal/api/handlers/aggregator/get_authorizations.go @@ -105,18 +105,18 @@ func (h *GetAuthorizationsHandler) parseRequest(r *http.Request) (aggregators.Ge // GetAuthorizationsResponse matches the lexicon output type GetAuthorizationsResponse struct { + Cursor *string `json:"cursor,omitempty"` Authorizations []CommunityAuthView `json:"authorizations"` - Cursor *string `json:"cursor,omitempty"` // Pagination cursor } // CommunityAuthView matches social.coves.aggregator.defs#communityAuthView // Shows authorization from aggregator's perspective with nested aggregator details type CommunityAuthView struct { - Aggregator AggregatorView `json:"aggregator"` // REQUIRED: Nested full aggregator object - Enabled bool `json:"enabled"` // REQUIRED Config interface{} `json:"config,omitempty"` - CreatedAt string `json:"createdAt"` // REQUIRED + Aggregator AggregatorView `json:"aggregator"` + CreatedAt string `json:"createdAt"` RecordUri string `json:"recordUri,omitempty"` + Enabled bool `json:"enabled"` } // toCommunityAuthView converts domain model to API view diff --git a/internal/api/handlers/aggregator/list_for_community.go b/internal/api/handlers/aggregator/list_for_community.go index 86c6406..d899ffb 100644 --- a/internal/api/handlers/aggregator/list_for_community.go +++ b/internal/api/handlers/aggregator/list_for_community.go @@ -111,24 +111,24 @@ func (e *requestError) Error() string { // ListForCommunityResponse matches the lexicon output type ListForCommunityResponse struct { + Cursor *string `json:"cursor,omitempty"` Aggregators []AuthorizationView `json:"aggregators"` - Cursor *string `json:"cursor,omitempty"` // Pagination cursor } // AuthorizationView matches social.coves.aggregator.defs#authorizationView // Shows authorization from community's perspective type AuthorizationView struct { - AggregatorDID string `json:"aggregatorDid"` - CommunityDID string `json:"communityDid"` - CommunityHandle *string `json:"communityHandle,omitempty"` // Optional: populated when communities service integration is complete - CommunityName *string `json:"communityName,omitempty"` // Optional: populated when communities service integration is complete - Enabled bool `json:"enabled"` Config interface{} `json:"config,omitempty"` - CreatedAt string `json:"createdAt"` // REQUIRED + CommunityHandle *string `json:"communityHandle,omitempty"` + CommunityName *string `json:"communityName,omitempty"` CreatedBy *string `json:"createdBy,omitempty"` DisabledAt *string `json:"disabledAt,omitempty"` DisabledBy *string `json:"disabledBy,omitempty"` + AggregatorDID string `json:"aggregatorDid"` + CommunityDID string `json:"communityDid"` + CreatedAt string `json:"createdAt"` RecordUri string `json:"recordUri,omitempty"` + Enabled bool `json:"enabled"` } // toAuthorizationView converts domain model to API view diff --git a/internal/atproto/auth/jwt.go b/internal/atproto/auth/jwt.go index aee7059..64789c8 100644 --- a/internal/atproto/auth/jwt.go +++ b/internal/atproto/auth/jwt.go @@ -10,6 +10,7 @@ import ( "fmt" "math/big" "net/url" + "os" "strings" "time" @@ -133,8 +134,19 @@ func validateClaims(claims *Claims) error { // Validate issuer is either an HTTPS URL or a DID // atProto uses DIDs (did:web:, did:plc:) or HTTPS URLs as issuer identifiers - if !strings.HasPrefix(claims.Issuer, "https://") && !strings.HasPrefix(claims.Issuer, "did:") { - return fmt.Errorf("issuer must be HTTPS URL or DID, got: %s", claims.Issuer) + // In dev mode (IS_DEV_ENV=true), allow HTTP for local PDS testing + isHTTP := strings.HasPrefix(claims.Issuer, "http://") + isHTTPS := strings.HasPrefix(claims.Issuer, "https://") + isDID := strings.HasPrefix(claims.Issuer, "did:") + + if !isHTTPS && !isDID && !isHTTP { + return fmt.Errorf("issuer must be HTTPS URL, HTTP URL (dev only), or DID, got: %s", claims.Issuer) + } + + // In production, reject HTTP issuers (only for non-dev environments) + // Check IS_DEV_ENV environment variable + if isHTTP && os.Getenv("IS_DEV_ENV") != "true" { + return fmt.Errorf("HTTP issuer not allowed in production, got: %s", claims.Issuer) } // Parse to ensure it's a valid URL diff --git a/internal/atproto/jetstream/aggregator_consumer.go b/internal/atproto/jetstream/aggregator_consumer.go index 9add373..49afd77 100644 --- a/internal/atproto/jetstream/aggregator_consumer.go +++ b/internal/atproto/jetstream/aggregator_consumer.go @@ -306,15 +306,15 @@ func parseAggregatorService(record interface{}) (*AggregatorServiceRecord, error // AggregatorAuthorizationRecord represents the authorization record structure type AggregatorAuthorizationRecord struct { + Config map[string]interface{} `json:"config,omitempty"` Type string `json:"$type"` - Aggregator string `json:"aggregatorDid"` // Aggregator DID - fixed field name - CommunityDid string `json:"communityDid"` // Community DID (must match repo DID) - Enabled bool `json:"enabled"` - Config map[string]interface{} `json:"config,omitempty"` // Aggregator-specific config - CreatedBy string `json:"createdBy"` // Required: DID of moderator who authorized + Aggregator string `json:"aggregatorDid"` + CommunityDid string `json:"communityDid"` + CreatedBy string `json:"createdBy"` DisabledBy string `json:"disabledBy,omitempty"` - DisabledAt string `json:"disabledAt,omitempty"` // When authorization was disabled (for modlog/audit) + DisabledAt string `json:"disabledAt,omitempty"` CreatedAt string `json:"createdAt"` + Enabled bool `json:"enabled"` } // parseAggregatorAuthorization parses an aggregator authorization record diff --git a/internal/core/aggregators/aggregator.go b/internal/core/aggregators/aggregator.go index 2d118f3..555a335 100644 --- a/internal/core/aggregators/aggregator.go +++ b/internal/core/aggregators/aggregator.go @@ -6,47 +6,47 @@ import "time" // Aggregators are autonomous services that can post content to communities after authorization // Following Bluesky's pattern: app.bsky.feed.generator and app.bsky.labeler.service type Aggregator struct { - DID string `json:"did" db:"did"` // Aggregator's DID (primary key) - DisplayName string `json:"displayName" db:"display_name"` // Human-readable name - Description string `json:"description,omitempty" db:"description"` // What the aggregator does - AvatarURL string `json:"avatarUrl,omitempty" db:"avatar_url"` // Optional avatar image URL - ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"` // JSON Schema for configuration (JSONB) - MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"` // Contact for support/issues - SourceURL string `json:"sourceUrl,omitempty" db:"source_url"` // Source code URL (transparency) - CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"` // Auto-updated by trigger - PostsCreated int `json:"postsCreated" db:"posts_created"` // Auto-updated by trigger - CreatedAt time.Time `json:"createdAt" db:"created_at"` // When aggregator was created (from lexicon) - IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` // When we indexed this record - RecordURI string `json:"recordUri,omitempty" db:"record_uri"` // at://did/social.coves.aggregator.service/self - RecordCID string `json:"recordCid,omitempty" db:"record_cid"` // Content hash + CreatedAt time.Time `json:"createdAt" db:"created_at"` + IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` + AvatarURL string `json:"avatarUrl,omitempty" db:"avatar_url"` + DID string `json:"did" db:"did"` + MaintainerDID string `json:"maintainerDid,omitempty" db:"maintainer_did"` + SourceURL string `json:"sourceUrl,omitempty" db:"source_url"` + Description string `json:"description,omitempty" db:"description"` + DisplayName string `json:"displayName" db:"display_name"` + RecordURI string `json:"recordUri,omitempty" db:"record_uri"` + RecordCID string `json:"recordCid,omitempty" db:"record_cid"` + ConfigSchema []byte `json:"configSchema,omitempty" db:"config_schema"` + CommunitiesUsing int `json:"communitiesUsing" db:"communities_using"` + PostsCreated int `json:"postsCreated" db:"posts_created"` } // Authorization represents a community's authorization for an aggregator // Stored in community's repository: at://community_did/social.coves.aggregator.authorization/{rkey} type Authorization struct { - ID int `json:"id" db:"id"` // Database ID - AggregatorDID string `json:"aggregatorDid" db:"aggregator_did"` // Which aggregator - CommunityDID string `json:"communityDid" db:"community_did"` // Which community - Enabled bool `json:"enabled" db:"enabled"` // Current status - Config []byte `json:"config,omitempty" db:"config"` // Aggregator-specific config (JSONB) - CreatedBy string `json:"createdBy,omitempty" db:"created_by"` // Moderator DID who enabled it - DisabledBy string `json:"disabledBy,omitempty" db:"disabled_by"` // Moderator DID who disabled it - CreatedAt time.Time `json:"createdAt" db:"created_at"` // When authorization was created - DisabledAt *time.Time `json:"disabledAt,omitempty" db:"disabled_at"` // When authorization was disabled (for modlog/audit) - IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` // When we indexed this record - RecordURI string `json:"recordUri,omitempty" db:"record_uri"` // at://community_did/social.coves.aggregator.authorization/{rkey} - RecordCID string `json:"recordCid,omitempty" db:"record_cid"` // Content hash + CreatedAt time.Time `json:"createdAt" db:"created_at"` + IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` + DisabledAt *time.Time `json:"disabledAt,omitempty" db:"disabled_at"` + AggregatorDID string `json:"aggregatorDid" db:"aggregator_did"` + CommunityDID string `json:"communityDid" db:"community_did"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + DisabledBy string `json:"disabledBy,omitempty" db:"disabled_by"` + RecordURI string `json:"recordUri,omitempty" db:"record_uri"` + RecordCID string `json:"recordCid,omitempty" db:"record_cid"` + Config []byte `json:"config,omitempty" db:"config"` + ID int `json:"id" db:"id"` + Enabled bool `json:"enabled" db:"enabled"` } // AggregatorPost represents tracking of posts created by aggregators // AppView-only table for rate limiting and statistics type AggregatorPost struct { - ID int `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` AggregatorDID string `json:"aggregatorDid" db:"aggregator_did"` CommunityDID string `json:"communityDid" db:"community_did"` PostURI string `json:"postUri" db:"post_uri"` PostCID string `json:"postCid" db:"post_cid"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` + ID int `json:"id" db:"id"` } // EnableAggregatorRequest represents input for enabling an aggregator in a community diff --git a/internal/core/communities/service.go b/internal/core/communities/service.go index 6fc9a72..a9d7353 100644 --- a/internal/core/communities/service.go +++ b/internal/core/communities/service.go @@ -474,7 +474,8 @@ func (s *communityService) EnsureFreshToken(ctx context.Context, community *Comm newAccessToken, newRefreshToken, err := refreshPDSToken(ctx, fresh.PDSURL, fresh.PDSAccessToken, fresh.PDSRefreshToken) if err != nil { // Check if refresh token expired (need password fallback) - if strings.Contains(err.Error(), "expired or invalid") { + // Match both "ExpiredToken" and "Token has expired" error messages + if strings.Contains(strings.ToLower(err.Error()), "expired") { log.Printf("[TOKEN-REFRESH] Community: %s, Event: refresh_token_expired, Message: Re-authenticating with password", fresh.DID) // Fallback: Re-authenticate with stored password diff --git a/tests/integration/discover_test.go b/tests/integration/discover_test.go index 7172a43..2da1c4d 100644 --- a/tests/integration/discover_test.go +++ b/tests/integration/discover_test.go @@ -1,6 +1,8 @@ package integration import ( + "Coves/internal/api/handlers/discover" + "Coves/internal/db/postgres" "context" "encoding/json" "fmt" @@ -9,9 +11,7 @@ import ( "testing" "time" - "Coves/internal/api/handlers/discover" discoverCore "Coves/internal/core/discover" - "Coves/internal/db/postgres" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/tests/integration/post_handler_test.go b/tests/integration/post_handler_test.go index 4983a0e..4a80568 100644 --- a/tests/integration/post_handler_test.go +++ b/tests/integration/post_handler_test.go @@ -546,7 +546,6 @@ func TestPostService_DIDValidationSecurity(t *testing.T) { } _, err := postService.CreatePost(ctx, postReq) - // May fail for other reasons (community not found), but NOT due to DID mismatch if err != nil { assert.NotContains(t, strings.ToLower(err.Error()), "does not match", diff --git a/tests/integration/timeline_test.go b/tests/integration/timeline_test.go index 8dc6c47..ad3eaf0 100644 --- a/tests/integration/timeline_test.go +++ b/tests/integration/timeline_test.go @@ -1,6 +1,9 @@ package integration import ( + "Coves/internal/api/handlers/timeline" + "Coves/internal/api/middleware" + "Coves/internal/db/postgres" "context" "encoding/json" "fmt" @@ -9,10 +12,7 @@ import ( "testing" "time" - "Coves/internal/api/handlers/timeline" - "Coves/internal/api/middleware" timelineCore "Coves/internal/core/timeline" - "Coves/internal/db/postgres" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require"