A community based topic aggregation platform built on atproto

Merge branch 'refactor/actor-community-lexicons-atproto-best-practices'

+2 -1
cmd/server/main.go
···
log.Println(" Set SKIP_DID_WEB_VERIFICATION=false for production")
}
-
communityEventConsumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, skipDIDWebVerification)
communityJetstreamConnector := jetstream.NewCommunityJetstreamConnector(communityEventConsumer, communityJetstreamURL)
go func() {
···
log.Println(" Set SKIP_DID_WEB_VERIFICATION=false for production")
}
+
// Pass identity resolver to consumer for PLC handle resolution (source of truth)
+
communityEventConsumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, skipDIDWebVerification, identityResolver)
communityJetstreamConnector := jetstream.NewCommunityJetstreamConnector(communityEventConsumer, communityJetstreamURL)
go func() {
+8 -1
cmd/validate-lexicon/main.go
···
// Post record types (removed - no longer exists in new structure)
// Actor definitions
-
"social.coves.actor.profile#geoLocation",
// Community definitions
"social.coves.community.rules#rule",
}
···
// Post record types (removed - no longer exists in new structure)
// Actor definitions
+
"social.coves.actor.defs#profileView",
+
"social.coves.actor.defs#profileViewDetailed",
+
"social.coves.actor.defs#profileStats",
+
"social.coves.actor.defs#viewerState",
// Community definitions
+
"social.coves.community.defs#communityView",
+
"social.coves.community.defs#communityViewDetailed",
+
"social.coves.community.defs#communityStats",
+
"social.coves.community.defs#viewerState",
"social.coves.community.rules#rule",
}
+65 -1
internal/atproto/jetstream/community_consumer.go
···
package jetstream
import (
"Coves/internal/atproto/utils"
"Coves/internal/core/communities"
"context"
···
// CommunityEventConsumer consumes community-related events from Jetstream
type CommunityEventConsumer struct {
repo communities.Repository // Repository for community operations
httpClient *http.Client // Shared HTTP client with connection pooling
didCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results
wellKnownLimiter *rate.Limiter // Rate limiter for .well-known fetches
···
// NewCommunityEventConsumer creates a new Jetstream consumer for community events
// instanceDID: The DID of this Coves instance (for hostedBy verification)
// skipVerification: Skip did:web verification (for dev mode)
-
func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool) *CommunityEventConsumer {
// Create bounded LRU cache for DID document verification results
// Max 1000 entries to prevent unbounded memory growth (PR review feedback)
// Each entry ~100 bytes → max ~100KB memory overhead
···
return &CommunityEventConsumer{
repo: repo,
instanceDID: instanceDID,
skipVerification: skipVerification,
// Shared HTTP client with connection pooling for .well-known fetches
···
return fmt.Errorf("failed to parse community profile: %w", err)
}
// SECURITY: Verify hostedBy claim matches handle domain
// This prevents malicious instances from claiming to host communities for domains they don't own
if err := c.verifyHostedByClaim(ctx, profile.Handle, profile.HostedBy); err != nil {
···
profile, err := parseCommunityProfile(commit.Record)
if err != nil {
return fmt.Errorf("failed to parse community profile: %w", err)
}
// V2: Repository DID IS the community DID
···
}
return &profile, nil
}
// extractContentVisibility extracts contentVisibility from subscription record with clamping
···
package jetstream
import (
+
"Coves/internal/atproto/identity"
"Coves/internal/atproto/utils"
"Coves/internal/core/communities"
"context"
···
// CommunityEventConsumer consumes community-related events from Jetstream
type CommunityEventConsumer struct {
repo communities.Repository // Repository for community operations
+
identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) } // For resolving handles from DIDs
httpClient *http.Client // Shared HTTP client with connection pooling
didCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results
wellKnownLimiter *rate.Limiter // Rate limiter for .well-known fetches
···
// NewCommunityEventConsumer creates a new Jetstream consumer for community events
// instanceDID: The DID of this Coves instance (for hostedBy verification)
// skipVerification: Skip did:web verification (for dev mode)
+
// identityResolver: Optional resolver for resolving handles from DIDs (can be nil for tests)
+
func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) }) *CommunityEventConsumer {
// Create bounded LRU cache for DID document verification results
// Max 1000 entries to prevent unbounded memory growth (PR review feedback)
// Each entry ~100 bytes → max ~100KB memory overhead
···
return &CommunityEventConsumer{
repo: repo,
+
identityResolver: identityResolver, // Optional - can be nil for tests
instanceDID: instanceDID,
skipVerification: skipVerification,
// Shared HTTP client with connection pooling for .well-known fetches
···
return fmt.Errorf("failed to parse community profile: %w", err)
}
+
// atProto Best Practice: Handles are NOT stored in records (they're mutable, resolved from DIDs)
+
// If handle is missing from record (new atProto-compliant records), resolve it from PLC/DID
+
if profile.Handle == "" {
+
if c.identityResolver != nil {
+
// Production: Resolve handle from PLC (source of truth)
+
// NO FALLBACK - if PLC is down, we fail and backfill later
+
// This prevents creating communities with incorrect handles in federated scenarios
+
identity, err := c.identityResolver.Resolve(ctx, did)
+
if err != nil {
+
return fmt.Errorf("failed to resolve handle from PLC for %s: %w (no fallback - will retry during backfill)", did, err)
+
}
+
profile.Handle = identity.Handle
+
log.Printf("✓ Resolved handle from PLC: %s (did=%s, method=%s)",
+
profile.Handle, did, identity.Method)
+
} else {
+
// Test mode only: construct deterministically when no resolver available
+
profile.Handle = constructHandleFromProfile(profile)
+
log.Printf("✓ Constructed handle (test mode): %s (name=%s, hostedBy=%s)",
+
profile.Handle, profile.Name, profile.HostedBy)
+
}
+
}
+
// SECURITY: Verify hostedBy claim matches handle domain
// This prevents malicious instances from claiming to host communities for domains they don't own
if err := c.verifyHostedByClaim(ctx, profile.Handle, profile.HostedBy); err != nil {
···
profile, err := parseCommunityProfile(commit.Record)
if err != nil {
return fmt.Errorf("failed to parse community profile: %w", err)
+
}
+
+
// atProto Best Practice: Handles are NOT stored in records (they're mutable, resolved from DIDs)
+
// If handle is missing from record (new atProto-compliant records), resolve it from PLC/DID
+
if profile.Handle == "" {
+
if c.identityResolver != nil {
+
// Production: Resolve handle from PLC (source of truth)
+
// NO FALLBACK - if PLC is down, we fail and backfill later
+
// This prevents creating communities with incorrect handles in federated scenarios
+
identity, err := c.identityResolver.Resolve(ctx, did)
+
if err != nil {
+
return fmt.Errorf("failed to resolve handle from PLC for %s: %w (no fallback - will retry during backfill)", did, err)
+
}
+
profile.Handle = identity.Handle
+
log.Printf("✓ Resolved handle from PLC: %s (did=%s, method=%s)",
+
profile.Handle, did, identity.Method)
+
} else {
+
// Test mode only: construct deterministically when no resolver available
+
profile.Handle = constructHandleFromProfile(profile)
+
log.Printf("✓ Constructed handle (test mode): %s (name=%s, hostedBy=%s)",
+
profile.Handle, profile.Name, profile.HostedBy)
+
}
}
// V2: Repository DID IS the community DID
···
}
return &profile, nil
+
}
+
+
// constructHandleFromProfile constructs a deterministic handle from profile data
+
// Format: {name}.community.{instanceDomain}
+
// Example: gaming.community.coves.social
+
// This is ONLY used in test mode (when identity resolver is nil)
+
// Production MUST resolve handles from PLC (source of truth)
+
// Returns empty string if hostedBy is not did:web format (caller will fail validation)
+
func constructHandleFromProfile(profile *CommunityProfile) string {
+
if !strings.HasPrefix(profile.HostedBy, "did:web:") {
+
// hostedBy must be did:web format for handle construction
+
// Return empty to trigger validation error in repository
+
return ""
+
}
+
instanceDomain := strings.TrimPrefix(profile.HostedBy, "did:web:")
+
return fmt.Sprintf("%s.community.%s", profile.Name, instanceDomain)
}
// extractContentVisibility extracts contentVisibility from subscription record with clamping
-33
internal/atproto/lexicon/social/coves/actor/block.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.block",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A block relationship where one user blocks another",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "did",
-
"description": "DID of the user being blocked"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the block was created"
-
},
-
"reason": {
-
"type": "string",
-
"maxGraphemes": 300,
-
"maxLength": 3000,
-
"description": "Optional reason for blocking"
-
}
-
}
-
}
-
}
-
}
-
}
···
-59
internal/atproto/lexicon/social/coves/actor/blockUser.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.blockUser",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Block another user",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "did",
-
"description": "DID of the user to block"
-
},
-
"reason": {
-
"type": "string",
-
"maxGraphemes": 300,
-
"maxLength": 3000,
-
"description": "Optional reason for blocking"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the created block record"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the created block record"
-
},
-
"existing": {
-
"type": "boolean",
-
"description": "True if user was already blocked"
-
}
-
}
-
}
-
},
-
"errors": [
-
{
-
"name": "SubjectNotFound",
-
"description": "Subject user not found"
-
}
-
]
-
}
-
}
-
}
···
+139
internal/atproto/lexicon/social/coves/actor/defs.json
···
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.actor.defs",
+
"defs": {
+
"profileView": {
+
"type": "object",
+
"description": "Basic profile view with essential information",
+
"required": ["did"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did"
+
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Current handle resolved from DID"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 64,
+
"maxLength": 640
+
},
+
"avatar": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to avatar image"
+
}
+
}
+
},
+
"profileViewDetailed": {
+
"type": "object",
+
"description": "Detailed profile view with stats and viewer state",
+
"required": ["did"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did"
+
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Current handle resolved from DID"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 64,
+
"maxLength": 640
+
},
+
"bio": {
+
"type": "string",
+
"maxGraphemes": 256,
+
"maxLength": 2560
+
},
+
"bioFacets": {
+
"type": "array",
+
"description": "Rich text annotations for bio",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"avatar": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to avatar image"
+
},
+
"banner": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to banner image"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"stats": {
+
"type": "ref",
+
"ref": "#profileStats",
+
"description": "Aggregated statistics"
+
},
+
"viewer": {
+
"type": "ref",
+
"ref": "#viewerState",
+
"description": "Viewer's relationship to this profile"
+
}
+
}
+
},
+
"profileStats": {
+
"type": "object",
+
"description": "Aggregated statistics for a user profile",
+
"properties": {
+
"postCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of posts created"
+
},
+
"commentCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of comments made"
+
},
+
"communityCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of communities subscribed to"
+
},
+
"reputation": {
+
"type": "integer",
+
"description": "Global reputation score"
+
},
+
"membershipCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of communities with membership status"
+
}
+
}
+
},
+
"viewerState": {
+
"type": "object",
+
"description": "The viewing user's relationship to this profile",
+
"properties": {
+
"blocked": {
+
"type": "boolean",
+
"description": "Whether the viewer has blocked this user"
+
},
+
"blockedBy": {
+
"type": "boolean",
+
"description": "Whether the viewer is blocked by this user"
+
},
+
"blockUri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the block record if viewer blocked this user"
+
}
+
}
+
}
+
}
+
}
+3 -76
internal/atproto/lexicon/social/coves/actor/getProfile.json
···
"output": {
"encoding": "application/json",
"schema": {
-
"type": "object",
-
"required": ["did", "profile"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"profile": {
-
"type": "ref",
-
"ref": "social.coves.actor.profile"
-
},
-
"stats": {
-
"type": "ref",
-
"ref": "#profileStats"
-
},
-
"viewer": {
-
"type": "ref",
-
"ref": "#viewerState",
-
"description": "Viewer's relationship to this profile"
-
}
-
}
-
}
-
}
-
},
-
"profileStats": {
-
"type": "object",
-
"description": "Aggregated statistics for a user profile",
-
"required": ["postCount", "commentCount", "communityCount", "savedCount", "reputation"],
-
"properties": {
-
"postCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Total number of posts created"
-
},
-
"commentCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Total number of comments made"
-
},
-
"communityCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Number of communities subscribed to"
-
},
-
"savedCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Number of saved items"
-
},
-
"reputation": {
-
"type": "integer",
-
"description": "Global reputation score"
-
},
-
"membershipCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Number of communities with membership status"
-
}
-
}
-
},
-
"viewerState": {
-
"type": "object",
-
"description": "The viewing user's relationship to this profile",
-
"properties": {
-
"blocked": {
-
"type": "boolean",
-
"description": "Whether the viewer has blocked this user"
-
},
-
"blockedBy": {
-
"type": "boolean",
-
"description": "Whether the viewer is blocked by this user"
-
},
-
"blockUri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the block record if viewer blocked this user"
}
}
}
···
"output": {
"encoding": "application/json",
"schema": {
+
"type": "ref",
+
"ref": "social.coves.actor.defs#profileViewDetailed",
+
"description": "Detailed profile view with stats and viewer state"
}
}
}
-85
internal/atproto/lexicon/social/coves/actor/getSaved.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.getSaved",
-
"defs": {
-
"main": {
-
"type": "query",
-
"description": "Get all saved posts and comments for the authenticated user",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {
-
"limit": {
-
"type": "integer",
-
"minimum": 1,
-
"maximum": 100,
-
"default": 50,
-
"description": "Number of items to return"
-
},
-
"cursor": {
-
"type": "string",
-
"description": "Cursor for pagination"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["post", "comment"],
-
"description": "Filter by content type (optional)"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["savedItems"],
-
"properties": {
-
"savedItems": {
-
"type": "array",
-
"description": "All saved items for the user",
-
"items": {
-
"type": "ref",
-
"ref": "#savedItemView"
-
}
-
},
-
"cursor": {
-
"type": "string",
-
"description": "Cursor for next page"
-
}
-
}
-
}
-
}
-
},
-
"savedItemView": {
-
"type": "object",
-
"required": ["uri", "subject", "type", "savedAt"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the saved record"
-
},
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the saved post or comment"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["post", "comment"],
-
"description": "Type of content that was saved"
-
},
-
"savedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the item was saved"
-
},
-
"note": {
-
"type": "string",
-
"description": "Optional note about why this was saved"
-
}
-
}
-
}
-
}
-
}
···
-198
internal/atproto/lexicon/social/coves/actor/preferences.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.preferences",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "User preferences and settings",
-
"key": "literal:self",
-
"record": {
-
"type": "object",
-
"properties": {
-
"feedPreferences": {
-
"type": "ref",
-
"ref": "#feedPreferences"
-
},
-
"contentFiltering": {
-
"type": "ref",
-
"ref": "#contentFiltering"
-
},
-
"notificationSettings": {
-
"type": "ref",
-
"ref": "#notificationSettings"
-
},
-
"privacySettings": {
-
"type": "ref",
-
"ref": "#privacySettings"
-
},
-
"displayPreferences": {
-
"type": "ref",
-
"ref": "#displayPreferences"
-
}
-
}
-
}
-
},
-
"feedPreferences": {
-
"type": "object",
-
"description": "Feed and content preferences",
-
"properties": {
-
"defaultFeed": {
-
"type": "string",
-
"enum": ["home", "all"],
-
"default": "home"
-
},
-
"defaultSort": {
-
"type": "string",
-
"enum": ["hot", "new", "top"],
-
"default": "hot",
-
"description": "Default sort order for community feeds"
-
},
-
"showNSFW": {
-
"type": "boolean",
-
"default": false
-
},
-
"blurNSFW": {
-
"type": "boolean",
-
"default": true,
-
"description": "Blur NSFW content until clicked"
-
},
-
"autoplayVideos": {
-
"type": "boolean",
-
"default": false
-
},
-
"infiniteScroll": {
-
"type": "boolean",
-
"default": true
-
}
-
}
-
},
-
"contentFiltering": {
-
"type": "object",
-
"description": "Content filtering preferences",
-
"properties": {
-
"blockedTags": {
-
"type": "array",
-
"items": {
-
"type": "string"
-
},
-
"description": "Tags to filter out from feeds"
-
},
-
"blockedCommunities": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "did"
-
},
-
"description": "Communities to filter out from /all feeds"
-
},
-
"mutedWords": {
-
"type": "array",
-
"items": {
-
"type": "string"
-
},
-
"description": "Words to filter out from content"
-
},
-
"languageFilter": {
-
"type": "array",
-
"items": {
-
"type": "string",
-
"format": "language"
-
},
-
"description": "Only show content in these languages"
-
}
-
}
-
},
-
"notificationSettings": {
-
"type": "object",
-
"description": "Notification preferences",
-
"properties": {
-
"postReplies": {
-
"type": "boolean",
-
"default": true
-
},
-
"commentReplies": {
-
"type": "boolean",
-
"default": true
-
},
-
"mentions": {
-
"type": "boolean",
-
"default": true
-
},
-
"upvotes": {
-
"type": "boolean",
-
"default": false
-
},
-
"newFollowers": {
-
"type": "boolean",
-
"default": true
-
},
-
"communityInvites": {
-
"type": "boolean",
-
"default": true
-
},
-
"moderatorNotifications": {
-
"type": "boolean",
-
"default": true,
-
"description": "Notifications for moderator actions in your communities"
-
}
-
}
-
},
-
"privacySettings": {
-
"type": "object",
-
"description": "Privacy preferences",
-
"properties": {
-
"profileVisibility": {
-
"type": "string",
-
"enum": ["public", "authenticated", "followers"],
-
"default": "public"
-
},
-
"showSubscriptions": {
-
"type": "boolean",
-
"default": true
-
},
-
"showSavedPosts": {
-
"type": "boolean",
-
"default": false
-
},
-
"showVoteHistory": {
-
"type": "boolean",
-
"default": false
-
},
-
"allowDMs": {
-
"type": "string",
-
"enum": ["everyone", "followers", "none"],
-
"default": "everyone"
-
}
-
}
-
},
-
"displayPreferences": {
-
"type": "object",
-
"description": "Display and UI preferences",
-
"properties": {
-
"theme": {
-
"type": "string",
-
"enum": ["light", "dark", "auto"],
-
"default": "auto"
-
},
-
"compactView": {
-
"type": "boolean",
-
"default": false
-
},
-
"showAvatars": {
-
"type": "boolean",
-
"default": true
-
},
-
"showThumbnails": {
-
"type": "boolean",
-
"default": true
-
},
-
"postsPerPage": {
-
"type": "integer",
-
"minimum": 10,
-
"maximum": 100,
-
"default": 25
-
}
-
}
-
}
-
}
-
}
···
+1 -172
internal/atproto/lexicon/social/coves/actor/profile.json
···
"key": "literal:self",
"record": {
"type": "object",
-
"required": ["handle", "createdAt"],
"properties": {
-
"handle": {
-
"type": "string",
-
"format": "handle",
-
"maxLength": 253,
-
"description": "User's handle"
-
},
"displayName": {
"type": "string",
"maxGraphemes": 64,
···
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 2000000
},
-
"verified": {
-
"type": "boolean",
-
"default": false,
-
"description": "Whether the user has completed phone verification"
-
},
-
"verifiedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the user was verified"
-
},
-
"verificationExpiresAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When verification expires"
-
},
-
"federatedFrom": {
-
"type": "string",
-
"knownValues": ["bluesky", "lemmy", "mastodon", "coves"],
-
"description": "Platform user federated from"
-
},
-
"federatedIdentity": {
-
"type": "ref",
-
"ref": "#federatedIdentity",
-
"description": "Identity information from federated platform"
-
},
-
"location": {
-
"type": "ref",
-
"ref": "#geoLocation"
-
},
"createdAt": {
"type": "string",
"format": "datetime"
-
},
-
"moderatedCommunities": {
-
"type": "array",
-
"description": "Communities the user currently moderates",
-
"items": {
-
"type": "string",
-
"format": "did"
-
}
-
},
-
"moderationHistory": {
-
"type": "array",
-
"description": "Historical record of all moderation roles",
-
"items": {
-
"type": "ref",
-
"ref": "#moderationRole"
-
}
-
},
-
"violations": {
-
"type": "array",
-
"description": "Record of rule violations across communities",
-
"items": {
-
"type": "ref",
-
"ref": "#violation"
-
}
}
-
}
-
}
-
},
-
"moderationRole": {
-
"type": "object",
-
"required": ["communityDid", "role", "startedAt"],
-
"properties": {
-
"communityDid": {
-
"type": "string",
-
"format": "did",
-
"description": "Community where moderation role was held"
-
},
-
"role": {
-
"type": "string",
-
"knownValues": ["moderator", "admin"],
-
"description": "Type of moderation role"
-
},
-
"startedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the role began"
-
},
-
"endedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the role ended (null if current)"
-
}
-
}
-
},
-
"violation": {
-
"type": "object",
-
"required": ["communityDid", "ruleViolated", "timestamp", "severity"],
-
"properties": {
-
"communityDid": {
-
"type": "string",
-
"format": "did",
-
"description": "Community where violation occurred"
-
},
-
"ruleViolated": {
-
"type": "string",
-
"description": "Description of the rule that was violated"
-
},
-
"timestamp": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the violation occurred"
-
},
-
"severity": {
-
"type": "string",
-
"knownValues": ["minor", "moderate", "major", "severe"],
-
"description": "Severity level of the violation"
-
},
-
"resolution": {
-
"type": "string",
-
"description": "How the violation was resolved"
-
},
-
"postUri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "Optional reference to the violating content"
-
}
-
}
-
},
-
"federatedIdentity": {
-
"type": "object",
-
"description": "Verified identity from a federated platform",
-
"required": ["did", "handle", "verifiedAt"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did",
-
"description": "Original DID from the federated platform"
-
},
-
"handle": {
-
"type": "string",
-
"maxLength": 253,
-
"description": "Original handle from the federated platform"
-
},
-
"verifiedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the federated identity was verified via OAuth"
-
},
-
"lastSyncedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "Last time profile data was synced from the federated platform"
-
},
-
"homePDS": {
-
"type": "string",
-
"description": "Home PDS server URL for the federated account"
-
}
-
}
-
},
-
"geoLocation": {
-
"type": "object",
-
"description": "Geographic location information",
-
"properties": {
-
"country": {
-
"type": "string",
-
"maxLength": 2,
-
"description": "ISO 3166-1 alpha-2 country code"
-
},
-
"region": {
-
"type": "string",
-
"maxLength": 128,
-
"description": "State/province/region name"
-
},
-
"displayName": {
-
"type": "string",
-
"maxLength": 256,
-
"description": "Human-readable location name"
}
}
}
···
"key": "literal:self",
"record": {
"type": "object",
+
"required": ["createdAt"],
"properties": {
"displayName": {
"type": "string",
"maxGraphemes": 64,
···
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 2000000
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
}
}
-63
internal/atproto/lexicon/social/coves/actor/saveItem.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.saveItem",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Save a post or comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject", "type"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment to save"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["post", "comment"],
-
"description": "Type of content being saved"
-
},
-
"note": {
-
"type": "string",
-
"maxLength": 300,
-
"description": "Optional note about why this was saved"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the created saved record"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the created saved record"
-
},
-
"existing": {
-
"type": "boolean",
-
"description": "True if item was already saved"
-
}
-
}
-
}
-
},
-
"errors": [
-
{
-
"name": "SubjectNotFound",
-
"description": "The post or comment to save was not found"
-
}
-
]
-
}
-
}
-
}
···
-37
internal/atproto/lexicon/social/coves/actor/saved.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.saved",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A saved post or comment",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "type", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment being saved"
-
},
-
"type": {
-
"type": "string",
-
"enum": ["post", "comment"],
-
"description": "Type of content being saved"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the item was saved"
-
},
-
"note": {
-
"type": "string",
-
"maxLength": 300,
-
"description": "Optional note about why this was saved"
-
}
-
}
-
}
-
}
-
}
-
}
···
-39
internal/atproto/lexicon/social/coves/actor/subscription.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.subscription",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A subscription to a community",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["community", "createdAt"],
-
"properties": {
-
"community": {
-
"type": "string",
-
"format": "at-identifier",
-
"description": "DID or handle of the community"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the subscription started"
-
},
-
"endedAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "When the subscription ended (null if current)"
-
},
-
"contentVisibility": {
-
"type": "integer",
-
"minimum": 1,
-
"maximum": 5,
-
"default": 3,
-
"description": "Content visibility level (1=only best content, 5=all content)"
-
}
-
}
-
}
-
}
-
}
-
}
···
-37
internal/atproto/lexicon/social/coves/actor/unblockUser.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.unblockUser",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Unblock a previously blocked user",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "did",
-
"description": "DID of the user to unblock"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {}
-
}
-
},
-
"errors": [
-
{
-
"name": "NotBlocked",
-
"description": "User is not currently blocked"
-
}
-
]
-
}
-
}
-
}
···
-37
internal/atproto/lexicon/social/coves/actor/unsaveItem.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.actor.unsaveItem",
-
"defs": {
-
"main": {
-
"type": "procedure",
-
"description": "Unsave a previously saved post or comment",
-
"input": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"required": ["subject"],
-
"properties": {
-
"subject": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the post or comment to unsave"
-
}
-
}
-
}
-
},
-
"output": {
-
"encoding": "application/json",
-
"schema": {
-
"type": "object",
-
"properties": {}
-
}
-
},
-
"errors": [
-
{
-
"name": "NotSaved",
-
"description": "Item is not currently saved"
-
}
-
]
-
}
-
}
-
}
···
-4
internal/atproto/lexicon/social/coves/actor/updateProfile.json
···
"type": "blob",
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 2000000
-
},
-
"location": {
-
"type": "ref",
-
"ref": "social.coves.actor.profile#geoLocation"
}
}
}
···
"type": "blob",
"accept": ["image/png", "image/jpeg", "image/webp"],
"maxSize": 2000000
}
}
}
+254
internal/atproto/lexicon/social/coves/community/defs.json
···
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.community.defs",
+
"defs": {
+
"communityView": {
+
"type": "object",
+
"description": "Basic community view with essential information and basic stats",
+
"required": ["did", "name"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the community"
+
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Current handle resolved from DID"
+
},
+
"name": {
+
"type": "string",
+
"maxLength": 64,
+
"description": "Short community name"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 128,
+
"maxLength": 1280,
+
"description": "Display name for the community"
+
},
+
"avatar": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to community avatar image"
+
},
+
"visibility": {
+
"type": "string",
+
"knownValues": ["public", "unlisted", "private"],
+
"description": "Community visibility level"
+
},
+
"subscriberCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of subscribers"
+
},
+
"memberCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of users with formal membership status"
+
},
+
"postCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of posts in the community"
+
},
+
"viewer": {
+
"type": "object",
+
"description": "Simplified viewer state for list views",
+
"properties": {
+
"subscribed": {
+
"type": "boolean",
+
"description": "Whether the viewer is subscribed"
+
},
+
"member": {
+
"type": "boolean",
+
"description": "Whether the viewer has membership status"
+
}
+
}
+
}
+
}
+
},
+
"communityViewDetailed": {
+
"type": "object",
+
"description": "Detailed community view with stats and viewer state",
+
"required": ["did", "name"],
+
"properties": {
+
"did": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the community"
+
},
+
"handle": {
+
"type": "string",
+
"format": "handle",
+
"description": "Current handle resolved from DID"
+
},
+
"name": {
+
"type": "string",
+
"maxLength": 64,
+
"description": "Short community name"
+
},
+
"displayName": {
+
"type": "string",
+
"maxGraphemes": 128,
+
"maxLength": 1280,
+
"description": "Display name for the community"
+
},
+
"description": {
+
"type": "string",
+
"maxGraphemes": 1000,
+
"maxLength": 10000,
+
"description": "Community description with rich text support"
+
},
+
"descriptionFacets": {
+
"type": "array",
+
"description": "Rich text annotations for description",
+
"items": {
+
"type": "ref",
+
"ref": "social.coves.richtext.facet"
+
}
+
},
+
"avatar": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to community avatar image"
+
},
+
"banner": {
+
"type": "string",
+
"format": "uri",
+
"description": "URL to community banner image"
+
},
+
"createdBy": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the user who created this community"
+
},
+
"createdByProfile": {
+
"type": "ref",
+
"ref": "social.coves.actor.defs#profileView",
+
"description": "Profile of the community creator"
+
},
+
"hostedBy": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the instance hosting this community"
+
},
+
"visibility": {
+
"type": "string",
+
"knownValues": ["public", "unlisted", "private"],
+
"description": "Community visibility level"
+
},
+
"moderationType": {
+
"type": "string",
+
"knownValues": ["moderator", "sortition"],
+
"description": "Type of moderation system"
+
},
+
"contentWarnings": {
+
"type": "array",
+
"description": "Required content warnings for this community",
+
"items": {
+
"type": "string",
+
"knownValues": ["nsfw", "violence", "spoilers"],
+
"maxLength": 32
+
}
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
},
+
"stats": {
+
"type": "ref",
+
"ref": "#communityStats",
+
"description": "Aggregated community statistics"
+
},
+
"viewer": {
+
"type": "ref",
+
"ref": "#viewerState",
+
"description": "Viewer's relationship to this community"
+
}
+
}
+
},
+
"communityStats": {
+
"type": "object",
+
"description": "Aggregated statistics for a community",
+
"properties": {
+
"memberCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of users with formal membership status"
+
},
+
"subscriberCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of subscribers"
+
},
+
"postCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of posts in the community"
+
},
+
"commentCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Total number of comments across all posts"
+
},
+
"activeUserCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of users active in the last 30 days"
+
},
+
"activePostersCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of unique posters in the last 30 days"
+
},
+
"moderatorCount": {
+
"type": "integer",
+
"minimum": 0,
+
"description": "Number of active moderators"
+
}
+
}
+
},
+
"viewerState": {
+
"type": "object",
+
"description": "The viewing user's relationship to this community",
+
"properties": {
+
"subscribed": {
+
"type": "boolean",
+
"description": "Whether the viewer is subscribed to this community"
+
},
+
"subscriptionUri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the viewer's subscription record if subscribed"
+
},
+
"member": {
+
"type": "boolean",
+
"description": "Whether the viewer has membership status (AppView-computed)"
+
},
+
"reputation": {
+
"type": "integer",
+
"description": "Viewer's reputation in this community"
+
},
+
"moderator": {
+
"type": "boolean",
+
"description": "Whether the viewer is a moderator of this community"
+
},
+
"creator": {
+
"type": "boolean",
+
"description": "Whether the viewer created this community"
+
},
+
"banned": {
+
"type": "boolean",
+
"description": "Whether the viewer is banned from this community"
+
},
+
"banUri": {
+
"type": "string",
+
"format": "at-uri",
+
"description": "AT-URI of the ban record if viewer is banned"
+
}
+
}
+
}
+
}
+
}
+3 -83
internal/atproto/lexicon/social/coves/community/get.json
···
"output": {
"encoding": "application/json",
"schema": {
-
"type": "object",
-
"required": ["did", "profile"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"profile": {
-
"type": "ref",
-
"ref": "social.coves.community.profile"
-
},
-
"stats": {
-
"type": "ref",
-
"ref": "#communityStats"
-
},
-
"viewer": {
-
"type": "ref",
-
"ref": "#viewerState",
-
"description": "Viewer's relationship to this community"
-
}
-
}
-
}
-
}
-
},
-
"communityStats": {
-
"type": "object",
-
"required": ["subscriberCount", "memberCount", "postCount", "activePostersCount"],
-
"properties": {
-
"subscriberCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Number of users subscribed to this community"
-
},
-
"memberCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Number of users with membership status"
-
},
-
"postCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Total number of posts in this community"
-
},
-
"activePostersCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Number of unique posters in the last 30 days"
-
},
-
"moderatorCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Number of active moderators"
-
}
-
}
-
},
-
"viewerState": {
-
"type": "object",
-
"description": "The viewing user's relationship to this community",
-
"properties": {
-
"subscribed": {
-
"type": "boolean",
-
"description": "Whether the viewer is subscribed"
-
},
-
"subscriptionUri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the subscription record if subscribed"
-
},
-
"member": {
-
"type": "boolean",
-
"description": "Whether the viewer has membership status (AppView-computed)"
-
},
-
"reputation": {
-
"type": "integer",
-
"description": "Viewer's reputation in this community"
-
},
-
"moderator": {
-
"type": "boolean",
-
"description": "Whether the viewer is a moderator"
-
},
-
"banned": {
-
"type": "boolean",
-
"description": "Whether the viewer is banned from this community"
}
}
}
···
"output": {
"encoding": "application/json",
"schema": {
+
"type": "ref",
+
"ref": "social.coves.community.defs#communityViewDetailed",
+
"description": "Detailed community view with stats and viewer state"
}
}
}
+1 -38
internal/atproto/lexicon/social/coves/community/list.json
···
"type": "array",
"items": {
"type": "ref",
-
"ref": "#communityView"
}
},
"cursor": {
"type": "string"
-
}
-
}
-
}
-
}
-
},
-
"communityView": {
-
"type": "object",
-
"required": ["did", "profile", "subscriberCount", "postCount"],
-
"properties": {
-
"did": {
-
"type": "string",
-
"format": "did"
-
},
-
"profile": {
-
"type": "ref",
-
"ref": "social.coves.community.profile"
-
},
-
"subscriberCount": {
-
"type": "integer",
-
"minimum": 0
-
},
-
"memberCount": {
-
"type": "integer",
-
"minimum": 0
-
},
-
"postCount": {
-
"type": "integer",
-
"minimum": 0
-
},
-
"viewer": {
-
"type": "object",
-
"properties": {
-
"subscribed": {
-
"type": "boolean"
-
},
-
"member": {
-
"type": "boolean"
}
}
}
···
"type": "array",
"items": {
"type": "ref",
+
"ref": "social.coves.community.defs#communityView"
}
},
"cursor": {
"type": "string"
}
}
}
+1 -96
internal/atproto/lexicon/social/coves/community/profile.json
···
"key": "literal:self",
"record": {
"type": "object",
-
"required": ["handle", "name", "createdAt", "createdBy", "hostedBy"],
"properties": {
-
"handle": {
-
"type": "string",
-
"maxLength": 253,
-
"format": "handle",
-
"description": "atProto handle (e.g., gaming.community.coves.social) - DNS-resolvable name for this community"
-
},
"name": {
"type": "string",
"maxLength": 64,
···
"maxLength": 64,
"description": "Community visibility level"
},
-
"federation": {
-
"type": "ref",
-
"ref": "#federationConfig",
-
"description": "Federation and discovery configuration"
-
},
-
"contentRules": {
-
"type": "ref",
-
"ref": "#contentRules",
-
"description": "Content posting rules and restrictions for this community"
-
},
"moderationType": {
"type": "string",
"knownValues": ["moderator", "sortition"],
···
"maxLength": 32
}
},
-
"memberCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Cached count of community members"
-
},
-
"subscriberCount": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Cached count of community subscribers"
-
},
-
"federatedFrom": {
-
"type": "string",
-
"knownValues": ["lemmy", "coves"],
-
"description": "Platform community originated from"
-
},
-
"federatedId": {
-
"type": "string",
-
"description": "Original ID on federated platform"
-
},
"createdAt": {
"type": "string",
"format": "datetime"
}
-
}
-
}
-
},
-
"federationConfig": {
-
"type": "object",
-
"description": "Federation and discovery configuration for this community",
-
"properties": {
-
"allowExternalDiscovery": {
-
"type": "boolean",
-
"default": true,
-
"description": "Whether other Coves instances can index and discover this community"
-
}
-
}
-
},
-
"contentRules": {
-
"type": "object",
-
"description": "Content posting rules and restrictions for this community. Rules are validated at post creation time.",
-
"properties": {
-
"allowedEmbedTypes": {
-
"type": "array",
-
"description": "Allowed embed types. Empty array = no embeds allowed. Null/undefined = all embed types allowed.",
-
"items": {
-
"type": "string",
-
"knownValues": ["images", "video", "external", "record"]
-
}
-
},
-
"requireText": {
-
"type": "boolean",
-
"default": false,
-
"description": "Whether posts must have text content (non-empty content field)"
-
},
-
"minTextLength": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Minimum character length for post content (0 = no minimum)"
-
},
-
"maxTextLength": {
-
"type": "integer",
-
"minimum": 1,
-
"description": "Maximum character length for post content (overrides global limit if lower)"
-
},
-
"requireTitle": {
-
"type": "boolean",
-
"default": false,
-
"description": "Whether posts must have a title"
-
},
-
"minImages": {
-
"type": "integer",
-
"minimum": 0,
-
"description": "Minimum number of images required (0 = no minimum). Only enforced if images embed is present."
-
},
-
"maxImages": {
-
"type": "integer",
-
"minimum": 1,
-
"description": "Maximum number of images allowed per post (overrides global limit if lower)"
-
},
-
"allowFederated": {
-
"type": "boolean",
-
"default": true,
-
"description": "Whether federated posts (e.g., from app.bsky) are allowed in this community"
}
}
}
···
"key": "literal:self",
"record": {
"type": "object",
+
"required": ["name", "createdAt", "createdBy", "hostedBy"],
"properties": {
"name": {
"type": "string",
"maxLength": 64,
···
"maxLength": 64,
"description": "Community visibility level"
},
"moderationType": {
"type": "string",
"knownValues": ["moderator", "sortition"],
···
"maxLength": 32
}
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
}
}
+1 -11
internal/core/communities/service.go
···
// Build community profile record
profile := map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": pdsAccount.Handle, // atProto handle (e.g., gaming.community.coves.social)
-
"name": req.Name, // Short name for !mentions (e.g., "gaming")
"visibility": req.Visibility,
"hostedBy": s.instanceDID, // V2: Instance hosts, community owns
"createdBy": req.CreatedByDID,
···
if req.Language != "" {
profile["language"] = req.Language
}
-
-
// Initialize counts
-
profile["memberCount"] = 0
-
profile["subscriberCount"] = 0
// TODO: Handle avatar and banner blobs
// For now, we'll skip blob uploads. This would require:
···
// Build updated profile record (start with existing)
profile := map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": existing.Handle,
"name": existing.Name,
"owner": existing.OwnerDID,
"createdBy": existing.CreatedByDID,
···
} else if len(existing.ContentWarnings) > 0 {
profile["contentWarnings"] = existing.ContentWarnings
}
-
-
// Preserve counts
-
profile["memberCount"] = existing.MemberCount
-
profile["subscriberCount"] = existing.SubscriberCount
// V2: Community profiles always use "self" as rkey
// (No need to extract from URI - it's always "self" for V2 communities)
···
// Build community profile record
profile := map[string]interface{}{
"$type": "social.coves.community.profile",
+
"name": req.Name, // Short name for !mentions (e.g., "gaming")
"visibility": req.Visibility,
"hostedBy": s.instanceDID, // V2: Instance hosts, community owns
"createdBy": req.CreatedByDID,
···
if req.Language != "" {
profile["language"] = req.Language
}
// TODO: Handle avatar and banner blobs
// For now, we'll skip blob uploads. This would require:
···
// Build updated profile record (start with existing)
profile := map[string]interface{}{
"$type": "social.coves.community.profile",
"name": existing.Name,
"owner": existing.OwnerDID,
"createdBy": existing.CreatedByDID,
···
} else if len(existing.ContentWarnings) > 0 {
profile["contentWarnings"] = existing.ContentWarnings
}
// V2: Community profiles always use "self" as rkey
// (No need to extract from URI - it's always "self" for V2 communities)
+6 -1
internal/db/postgres/community_repo.go
···
// Create inserts a new community into the communities table
func (r *postgresCommunityRepo) Create(ctx context.Context, community *communities.Community) (*communities.Community, error) {
query := `
INSERT INTO communities (
did, handle, name, display_name, description, description_facets,
···
err := r.db.QueryRowContext(ctx, query,
community.DID,
-
community.Handle,
community.Name,
nullString(community.DisplayName),
nullString(community.Description),
···
// Create inserts a new community into the communities table
func (r *postgresCommunityRepo) Create(ctx context.Context, community *communities.Community) (*communities.Community, error) {
+
// Validate that handle is always provided (constructed by consumer)
+
if community.Handle == "" {
+
return nil, fmt.Errorf("handle is required (should be constructed by consumer before insert)")
+
}
+
query := `
INSERT INTO communities (
did, handle, name, display_name, description, description_facets,
···
err := r.db.QueryRowContext(ctx, query,
community.DID,
+
community.Handle, // Always non-empty - constructed by AppView consumer
community.Name,
nullString(community.DisplayName),
nullString(community.Description),
+2 -1
tests/integration/community_blocking_test.go
···
repo := createBlockingTestCommunityRepo(t, db)
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
// Create test community
testDID := fmt.Sprintf("did:plc:test-community-%d", time.Now().UnixNano())
···
repo := createBlockingTestCommunityRepo(t, db)
// Skip verification in tests
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, nil)
// Create test community
testDID := fmt.Sprintf("did:plc:test-community-%d", time.Now().UnixNano())
+271 -24
tests/integration/community_consumer_test.go
···
package integration
import (
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
"Coves/internal/db/postgres"
"context"
"fmt"
"testing"
"time"
···
}()
repo := postgres.NewCommunityRepository(db)
-
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
ctx := context.Background()
t.Run("creates community from firehose event", func(t *testing.T) {
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
// Simulate a Jetstream commit event
event := &jetstream.JetstreamEvent{
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
-
"did": communityDID, // Community's unique DID
-
"handle": fmt.Sprintf("!test-community-%s@coves.local", uniqueSuffix),
-
"name": "test-community",
"displayName": "Test Community",
"description": "A test community",
"owner": "did:web:coves.local",
···
"federation": map[string]interface{}{
"allowExternalDiscovery": true,
},
-
"memberCount": 0,
-
"subscriberCount": 0,
-
"createdAt": time.Now().Format(time.RFC3339),
},
},
}
···
t.Run("updates existing community", func(t *testing.T) {
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
-
handle := fmt.Sprintf("!update-test-%s@coves.local", uniqueSuffix)
// Create initial community
initialCommunity := &communities.Community{
DID: communityDID,
-
Handle: handle,
-
Name: "update-test",
DisplayName: "Original Name",
Description: "Original description",
OwnerDID: "did:web:coves.local",
···
RKey: "self",
CID: "bafy456def",
Record: map[string]interface{}{
-
"did": communityDID, // Community's unique DID
-
"handle": handle,
"name": "update-test",
"displayName": "Updated Name",
"description": "Updated description",
···
"federation": map[string]interface{}{
"allowExternalDiscovery": false,
},
-
"memberCount": 5,
-
"subscriberCount": 10,
-
"createdAt": time.Now().Format(time.RFC3339),
},
},
}
···
t.Run("deletes community", func(t *testing.T) {
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
// Create community to delete
community := &communities.Community{
DID: communityDID,
-
Handle: fmt.Sprintf("!delete-test-%s@coves.local", uniqueSuffix),
-
Name: "delete-test",
OwnerDID: "did:web:coves.local",
CreatedByDID: "did:plc:user123",
HostedByDID: "did:web:coves.local",
···
}()
repo := postgres.NewCommunityRepository(db)
-
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
ctx := context.Background()
t.Run("creates subscription from event", func(t *testing.T) {
// Create a community first
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
community := &communities.Community{
DID: communityDID,
-
Handle: fmt.Sprintf("!sub-test-%s@coves.local", uniqueSuffix),
-
Name: "sub-test",
OwnerDID: "did:web:coves.local",
CreatedByDID: "did:plc:user123",
HostedByDID: "did:web:coves.local",
···
}()
repo := postgres.NewCommunityRepository(db)
-
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
ctx := context.Background()
t.Run("ignores identity events", func(t *testing.T) {
···
}
})
}
···
package integration
import (
+
"Coves/internal/atproto/identity"
"Coves/internal/atproto/jetstream"
"Coves/internal/core/communities"
"Coves/internal/db/postgres"
"context"
+
"errors"
"fmt"
"testing"
"time"
···
}()
repo := postgres.NewCommunityRepository(db)
ctx := context.Background()
t.Run("creates community from firehose event", func(t *testing.T) {
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+
communityName := fmt.Sprintf("test-community-%s", uniqueSuffix)
+
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
+
// Set up mock resolver for this test DID
+
mockResolver := newMockIdentityResolver()
+
mockResolver.resolutions[communityDID] = expectedHandle
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, mockResolver)
// Simulate a Jetstream commit event
event := &jetstream.JetstreamEvent{
···
RKey: "self",
CID: "bafy123abc",
Record: map[string]interface{}{
+
// Note: No 'did', 'handle', 'memberCount', or 'subscriberCount' in record
+
// These are resolved/computed by AppView, not stored in immutable records
+
"name": communityName,
"displayName": "Test Community",
"description": "A test community",
"owner": "did:web:coves.local",
···
"federation": map[string]interface{}{
"allowExternalDiscovery": true,
},
+
"createdAt": time.Now().Format(time.RFC3339),
},
},
}
···
t.Run("updates existing community", func(t *testing.T) {
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+
communityName := "update-test"
+
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
+
// Set up mock resolver for this test DID
+
mockResolver := newMockIdentityResolver()
+
mockResolver.resolutions[communityDID] = expectedHandle
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, mockResolver)
// Create initial community
initialCommunity := &communities.Community{
DID: communityDID,
+
Handle: expectedHandle,
+
Name: communityName,
DisplayName: "Original Name",
Description: "Original description",
OwnerDID: "did:web:coves.local",
···
RKey: "self",
CID: "bafy456def",
Record: map[string]interface{}{
+
// Note: No 'did', 'handle', 'memberCount', or 'subscriberCount' in record
+
// These are resolved/computed by AppView, not stored in immutable records
"name": "update-test",
"displayName": "Updated Name",
"description": "Updated description",
···
"federation": map[string]interface{}{
"allowExternalDiscovery": false,
},
+
"createdAt": time.Now().Format(time.RFC3339),
},
},
}
···
t.Run("deletes community", func(t *testing.T) {
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+
communityName := "delete-test"
+
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
+
// Set up mock resolver for this test DID
+
mockResolver := newMockIdentityResolver()
+
mockResolver.resolutions[communityDID] = expectedHandle
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, mockResolver)
// Create community to delete
community := &communities.Community{
DID: communityDID,
+
Handle: expectedHandle,
+
Name: communityName,
OwnerDID: "did:web:coves.local",
CreatedByDID: "did:plc:user123",
HostedByDID: "did:web:coves.local",
···
}()
repo := postgres.NewCommunityRepository(db)
ctx := context.Background()
t.Run("creates subscription from event", func(t *testing.T) {
// Create a community first
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+
communityName := "sub-test"
+
expectedHandle := fmt.Sprintf("%s.community.coves.local", communityName)
+
+
// Set up mock resolver for this test DID
+
mockResolver := newMockIdentityResolver()
+
mockResolver.resolutions[communityDID] = expectedHandle
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, mockResolver)
community := &communities.Community{
DID: communityDID,
+
Handle: expectedHandle,
+
Name: communityName,
OwnerDID: "did:web:coves.local",
CreatedByDID: "did:plc:user123",
HostedByDID: "did:web:coves.local",
···
}()
repo := postgres.NewCommunityRepository(db)
+
// Use mock resolver (though these tests don't create communities, so it won't be called)
+
mockResolver := newMockIdentityResolver()
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, mockResolver)
ctx := context.Background()
t.Run("ignores identity events", func(t *testing.T) {
···
}
})
}
+
+
// mockIdentityResolver is a test double for identity resolution
+
type mockIdentityResolver struct {
+
// Map of DID -> handle for successful resolutions
+
resolutions map[string]string
+
// If true, Resolve returns an error
+
shouldFail bool
+
// Track calls to verify invocation
+
callCount int
+
lastDID string
+
}
+
+
func newMockIdentityResolver() *mockIdentityResolver {
+
return &mockIdentityResolver{
+
resolutions: make(map[string]string),
+
}
+
}
+
+
func (m *mockIdentityResolver) Resolve(ctx context.Context, did string) (*identity.Identity, error) {
+
m.callCount++
+
m.lastDID = did
+
+
if m.shouldFail {
+
return nil, errors.New("mock PLC resolution failure")
+
}
+
+
handle, ok := m.resolutions[did]
+
if !ok {
+
return nil, fmt.Errorf("no resolution configured for DID: %s", did)
+
}
+
+
return &identity.Identity{
+
DID: did,
+
Handle: handle,
+
PDSURL: "https://pds.example.com",
+
ResolvedAt: time.Now(),
+
Method: identity.MethodHTTPS,
+
}, nil
+
}
+
+
func TestCommunityConsumer_PLCHandleResolution(t *testing.T) {
+
db := setupTestDB(t)
+
defer func() {
+
if err := db.Close(); err != nil {
+
t.Logf("Failed to close database: %v", err)
+
}
+
}()
+
+
repo := postgres.NewCommunityRepository(db)
+
ctx := context.Background()
+
+
t.Run("resolves handle from PLC successfully", func(t *testing.T) {
+
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
+
communityDID := generateTestDID(uniqueSuffix)
+
communityName := fmt.Sprintf("test-plc-%s", uniqueSuffix)
+
expectedHandle := fmt.Sprintf("%s.community.coves.social", communityName)
+
+
// Create mock resolver
+
mockResolver := newMockIdentityResolver()
+
mockResolver.resolutions[communityDID] = expectedHandle
+
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, mockResolver)
+
+
// Simulate Jetstream event without handle in record
+
event := &jetstream.JetstreamEvent{
+
Did: communityDID,
+
TimeUS: time.Now().UnixMicro(),
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Rev: "rev123",
+
Operation: "create",
+
Collection: "social.coves.community.profile",
+
RKey: "self",
+
CID: "bafy123abc",
+
Record: map[string]interface{}{
+
// No handle field - should trigger PLC resolution
+
"name": communityName,
+
"displayName": "Test PLC Community",
+
"description": "Testing PLC resolution",
+
"owner": "did:web:coves.local",
+
"createdBy": "did:plc:user123",
+
"hostedBy": "did:web:coves.local",
+
"visibility": "public",
+
"federation": map[string]interface{}{
+
"allowExternalDiscovery": true,
+
},
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
// Handle the event
+
if err := consumer.HandleEvent(ctx, event); err != nil {
+
t.Fatalf("Failed to handle event: %v", err)
+
}
+
+
// Verify mock was called
+
if mockResolver.callCount != 1 {
+
t.Errorf("Expected 1 PLC resolution call, got %d", mockResolver.callCount)
+
}
+
if mockResolver.lastDID != communityDID {
+
t.Errorf("Expected PLC resolution for DID %s, got %s", communityDID, mockResolver.lastDID)
+
}
+
+
// Verify community was indexed with PLC-resolved handle
+
community, err := repo.GetByDID(ctx, communityDID)
+
if err != nil {
+
t.Fatalf("Failed to get indexed community: %v", err)
+
}
+
+
if community.Handle != expectedHandle {
+
t.Errorf("Expected handle %s from PLC, got %s", expectedHandle, community.Handle)
+
}
+
})
+
+
t.Run("fails when PLC resolution fails (no fallback)", func(t *testing.T) {
+
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
+
communityDID := generateTestDID(uniqueSuffix)
+
communityName := fmt.Sprintf("test-plc-fail-%s", uniqueSuffix)
+
+
// Create mock resolver that fails
+
mockResolver := newMockIdentityResolver()
+
mockResolver.shouldFail = true
+
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, mockResolver)
+
+
// Simulate Jetstream event without handle in record
+
event := &jetstream.JetstreamEvent{
+
Did: communityDID,
+
TimeUS: time.Now().UnixMicro(),
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Rev: "rev456",
+
Operation: "create",
+
Collection: "social.coves.community.profile",
+
RKey: "self",
+
CID: "bafy456def",
+
Record: map[string]interface{}{
+
"name": communityName,
+
"displayName": "Test PLC Failure",
+
"description": "Testing PLC failure",
+
"owner": "did:web:coves.local",
+
"createdBy": "did:plc:user123",
+
"hostedBy": "did:web:coves.local",
+
"visibility": "public",
+
"federation": map[string]interface{}{
+
"allowExternalDiscovery": true,
+
},
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
// Handle the event - should fail
+
err := consumer.HandleEvent(ctx, event)
+
if err == nil {
+
t.Fatal("Expected error when PLC resolution fails, got nil")
+
}
+
+
// Verify error message indicates PLC failure
+
expectedErrSubstring := "failed to resolve handle from PLC"
+
if !contains(err.Error(), expectedErrSubstring) {
+
t.Errorf("Expected error containing '%s', got: %v", expectedErrSubstring, err)
+
}
+
+
// Verify community was NOT indexed
+
_, err = repo.GetByDID(ctx, communityDID)
+
if !communities.IsNotFound(err) {
+
t.Errorf("Expected community NOT to be indexed when PLC fails, but got: %v", err)
+
}
+
+
// Verify mock was called (failure happened during resolution, not before)
+
if mockResolver.callCount != 1 {
+
t.Errorf("Expected 1 PLC resolution attempt, got %d", mockResolver.callCount)
+
}
+
})
+
+
t.Run("test mode rejects invalid hostedBy format", func(t *testing.T) {
+
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
+
communityDID := generateTestDID(uniqueSuffix)
+
communityName := fmt.Sprintf("test-invalid-hosted-%s", uniqueSuffix)
+
+
// No identity resolver (test mode)
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, nil)
+
+
// Event with invalid hostedBy format (not did:web)
+
event := &jetstream.JetstreamEvent{
+
Did: communityDID,
+
TimeUS: time.Now().UnixMicro(),
+
Kind: "commit",
+
Commit: &jetstream.CommitEvent{
+
Rev: "rev789",
+
Operation: "create",
+
Collection: "social.coves.community.profile",
+
RKey: "self",
+
CID: "bafy789ghi",
+
Record: map[string]interface{}{
+
"name": communityName,
+
"displayName": "Test Invalid HostedBy",
+
"description": "Testing validation",
+
"owner": "did:web:coves.local",
+
"createdBy": "did:plc:user123",
+
"hostedBy": "did:plc:invalid", // Invalid format - not did:web
+
"visibility": "public",
+
"federation": map[string]interface{}{
+
"allowExternalDiscovery": true,
+
},
+
"createdAt": time.Now().Format(time.RFC3339),
+
},
+
},
+
}
+
+
// Handle the event - should fail due to empty handle
+
err := consumer.HandleEvent(ctx, event)
+
if err == nil {
+
t.Fatal("Expected error for invalid hostedBy format in test mode, got nil")
+
}
+
+
// Verify error is about handle being required
+
expectedErrSubstring := "handle is required"
+
if !contains(err.Error(), expectedErrSubstring) {
+
t.Errorf("Expected error containing '%s', got: %v", expectedErrSubstring, err)
+
}
+
})
+
}
+9 -11
tests/integration/community_e2e_test.go
···
svc.SetPDSAccessToken(accessToken)
}
-
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(communityRepo, "did:web:coves.local", true)
// Setup HTTP server with XRPC routes
r := chi.NewRouter()
···
t.Logf(" Record value:\n %s", string(recordJSON))
}
-
// V2: DID is NOT in the record - it's in the repository URI
-
// The record should have handle, name, etc. but no 'did' field
-
// This matches Bluesky's app.bsky.actor.profile pattern
-
if pdsRecord.Value["handle"] != community.Handle {
-
t.Errorf("Community handle mismatch in PDS record: expected %s, got %v",
-
community.Handle, pdsRecord.Value["handle"])
-
}
// ====================================================================================
// Part 2: TRUE E2E - Real Jetstream Firehose Consumer
···
Collection: "social.coves.community.profile",
RKey: rkey,
Record: map[string]interface{}{
-
"did": createResp.DID, // Community's DID from response
-
"handle": createResp.Handle, // Community's handle from response
"name": createReq["name"],
"displayName": createReq["displayName"],
"description": createReq["description"],
"visibility": createReq["visibility"],
// Server-side derives these from JWT auth (instanceDID is the authenticated user)
"createdBy": instanceDID,
"hostedBy": instanceDID,
"federation": map[string]interface{}{
···
svc.SetPDSAccessToken(accessToken)
}
+
// Use real identity resolver with local PLC for production-like testing
+
consumer := jetstream.NewCommunityEventConsumer(communityRepo, "did:web:coves.local", true, identityResolver)
// Setup HTTP server with XRPC routes
r := chi.NewRouter()
···
t.Logf(" Record value:\n %s", string(recordJSON))
}
+
// V2: DID and Handle are NOT in the record - they're resolved from the repository URI
+
// The record should have name, hostedBy, createdBy, etc. but no 'did' or 'handle' fields
+
// This matches Bluesky's app.bsky.actor.profile pattern (no handle in record)
+
// Handles are mutable and resolved from DIDs via PLC, so they shouldn't be stored in immutable records
// ====================================================================================
// Part 2: TRUE E2E - Real Jetstream Firehose Consumer
···
Collection: "social.coves.community.profile",
RKey: rkey,
Record: map[string]interface{}{
+
// Note: No 'did' or 'handle' in record (atProto best practice)
+
// These are mutable and resolved from DIDs, not stored in immutable records
"name": createReq["name"],
"displayName": createReq["displayName"],
"description": createReq["description"],
"visibility": createReq["visibility"],
// Server-side derives these from JWT auth (instanceDID is the authenticated user)
+
"owner": instanceDID,
"createdBy": instanceDID,
"hostedBy": instanceDID,
"federation": map[string]interface{}{
+10 -5
tests/integration/community_hostedby_security_test.go
···
t.Run("rejects community with mismatched hostedBy domain", func(t *testing.T) {
// Create consumer with verification enabled
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
t.Run("accepts community with matching hostedBy domain", func(t *testing.T) {
// Create consumer with verification enabled
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
t.Run("rejects hostedBy with non-did:web format", func(t *testing.T) {
// Create consumer with verification enabled
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
t.Run("skip verification flag bypasses all checks", func(t *testing.T) {
// Create consumer with verification DISABLED
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", true)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
t.Run("rejects community with mismatched hostedBy domain", func(t *testing.T) {
// Create consumer with verification enabled
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false, nil)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
t.Run("accepts community with matching hostedBy domain", func(t *testing.T) {
// Create consumer with verification enabled
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false, nil)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
t.Run("rejects hostedBy with non-did:web format", func(t *testing.T) {
// Create consumer with verification enabled
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false, nil)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
t.Run("skip verification flag bypasses all checks", func(t *testing.T) {
// Create consumer with verification DISABLED
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", true, nil)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
···
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.social", false, nil)
uniqueSuffix := fmt.Sprintf("%d", time.Now().UnixNano())
communityDID := generateTestDID(uniqueSuffix)
+4 -2
tests/integration/community_v2_validation_test.go
···
repo := postgres.NewCommunityRepository(db)
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
ctx := context.Background()
t.Run("accepts V2 community with rkey=self", func(t *testing.T) {
···
repo := postgres.NewCommunityRepository(db)
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
ctx := context.Background()
t.Run("indexes community with atProto handle", func(t *testing.T) {
···
repo := postgres.NewCommunityRepository(db)
// Skip verification in tests
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, nil)
ctx := context.Background()
t.Run("accepts V2 community with rkey=self", func(t *testing.T) {
···
repo := postgres.NewCommunityRepository(db)
// Skip verification in tests
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, nil)
ctx := context.Background()
t.Run("indexes community with atProto handle", func(t *testing.T) {
+6 -3
tests/integration/subscription_indexing_test.go
···
repo := createTestCommunityRepo(t, db)
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
// Create a test community first (with unique DID)
testDID := fmt.Sprintf("did:plc:test-community-%d", time.Now().UnixNano())
···
repo := createTestCommunityRepo(t, db)
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
// Create test community (with unique DID)
testDID := fmt.Sprintf("did:plc:test-unsub-%d", time.Now().UnixNano())
···
repo := createTestCommunityRepo(t, db)
// Skip verification in tests
-
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true)
// Create test community (with unique DID)
testDID := fmt.Sprintf("did:plc:test-subcount-%d", time.Now().UnixNano())
···
repo := createTestCommunityRepo(t, db)
// Skip verification in tests
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, nil)
// Create a test community first (with unique DID)
testDID := fmt.Sprintf("did:plc:test-community-%d", time.Now().UnixNano())
···
repo := createTestCommunityRepo(t, db)
// Skip verification in tests
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, nil)
// Create test community (with unique DID)
testDID := fmt.Sprintf("did:plc:test-unsub-%d", time.Now().UnixNano())
···
repo := createTestCommunityRepo(t, db)
// Skip verification in tests
+
// Pass nil for identity resolver - not needed since consumer constructs handles from DIDs
+
consumer := jetstream.NewCommunityEventConsumer(repo, "did:web:coves.local", true, nil)
// Create test community (with unique DID)
testDID := fmt.Sprintf("did:plc:test-subcount-%d", time.Now().UnixNano())
+12
tests/integration/user_test.go
···
return db
}
// generateTestDID generates a unique test DID for integration tests
// V2.0: No longer uses DID generator - just creates valid did:plc strings
func generateTestDID(suffix string) string {
···
return db
}
+
// setupIdentityResolver creates an identity resolver configured for local PLC testing
+
func setupIdentityResolver(db *sql.DB) interface{ Resolve(context.Context, string) (*identity.Identity, error) } {
+
plcURL := os.Getenv("PLC_DIRECTORY_URL")
+
if plcURL == "" {
+
plcURL = "http://localhost:3002" // Local PLC directory
+
}
+
+
config := identity.DefaultConfig()
+
config.PLCURL = plcURL
+
return identity.NewResolver(db, config)
+
}
+
// generateTestDID generates a unique test DID for integration tests
// V2.0: No longer uses DID generator - just creates valid did:plc strings
func generateTestDID(suffix string) string {
-5
tests/lexicon-test-data/actor/block-invalid-did.json
···
-
{
-
"$type": "social.coves.actor.block",
-
"subject": "not-a-valid-did",
-
"createdAt": "2025-01-05T09:15:00Z"
-
}
···
-6
tests/lexicon-test-data/actor/block-valid.json
···
-
{
-
"$type": "social.coves.actor.block",
-
"subject": "did:plc:blockeduser123",
-
"createdAt": "2025-01-05T09:15:00Z",
-
"reason": "Repeated harassment and spam"
-
}
···
-7
tests/lexicon-test-data/actor/preferences-invalid-enum.json
···
-
{
-
"$type": "social.coves.actor.preferences",
-
"feedPreferences": {
-
"defaultFeed": "invalid-feed-type",
-
"defaultSort": "hot"
-
}
-
}
···
-40
tests/lexicon-test-data/actor/preferences-valid.json
···
-
{
-
"$type": "social.coves.actor.preferences",
-
"feedPreferences": {
-
"defaultFeed": "home",
-
"defaultSort": "hot",
-
"showNSFW": false,
-
"blurNSFW": true,
-
"autoplayVideos": true,
-
"infiniteScroll": true
-
},
-
"contentFiltering": {
-
"blockedTags": ["politics", "spoilers"],
-
"blockedCommunities": ["did:plc:controversialcommunity"],
-
"mutedWords": ["spam", "scam"],
-
"languageFilter": ["en", "es"]
-
},
-
"notificationSettings": {
-
"postReplies": true,
-
"commentReplies": true,
-
"mentions": true,
-
"upvotes": false,
-
"newFollowers": true,
-
"communityInvites": true,
-
"moderatorNotifications": true
-
},
-
"privacySettings": {
-
"profileVisibility": "public",
-
"showSubscriptions": true,
-
"showSavedPosts": false,
-
"showVoteHistory": false,
-
"allowDMs": "followers"
-
},
-
"displayPreferences": {
-
"theme": "dark",
-
"compactView": false,
-
"showAvatars": true,
-
"showThumbnails": true,
-
"postsPerPage": 25
-
}
-
}
···
-6
tests/lexicon-test-data/actor/profile-invalid-handle-format.json
···
-
{
-
"$type": "social.coves.actor.profile",
-
"handle": "invalid handle with spaces",
-
"displayName": "Test User",
-
"createdAt": "2024-01-01T00:00:00Z"
-
}
···
-4
tests/lexicon-test-data/actor/profile-invalid-missing-handle.json
···
-
{
-
"$type": "social.coves.actor.profile",
-
"displayName": "Missing Required Fields"
-
}
···
-1
tests/lexicon-test-data/actor/profile-valid.json
···
{
"$type": "social.coves.actor.profile",
-
"handle": "alice.example.com",
"displayName": "Alice Johnson",
"bio": "Software developer passionate about open-source",
"createdAt": "2024-01-15T10:30:00Z"
···
{
"$type": "social.coves.actor.profile",
"displayName": "Alice Johnson",
"bio": "Software developer passionate about open-source",
"createdAt": "2024-01-15T10:30:00Z"
-6
tests/lexicon-test-data/actor/saved-invalid-type.json
···
-
{
-
"$type": "social.coves.actor.saved",
-
"subject": "at://$1/social.coves.community.post/3k7a3dmb5bk2c",
-
"type": "article",
-
"createdAt": "2025-01-09T14:30:00Z"
-
}
···
-7
tests/lexicon-test-data/actor/saved-valid.json
···
-
{
-
"$type": "social.coves.actor.saved",
-
"subject": "at://$1/social.coves.community.post/3k7a3dmb5bk2c",
-
"type": "post",
-
"createdAt": "2025-01-09T14:30:00Z",
-
"note": "Great tutorial on Go concurrency patterns"
-
}
···
+9 -8
tests/lexicon_validation_test.go
···
// Test specific cross-references that should work
crossRefs := map[string]string{
-
"social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
-
"social.coves.actor.profile#geoLocation": "geoLocation definition in actor profile",
-
"social.coves.community.rules#rule": "rule definition in community rules",
}
for ref, description := range crossRefs {
···
recordType: "social.coves.actor.profile",
recordData: map[string]interface{}{
"$type": "social.coves.actor.profile",
-
"handle": "alice.example.com",
"displayName": "Alice Johnson",
"createdAt": "2024-01-15T10:30:00Z",
},
···
recordData: map[string]interface{}{
"$type": "social.coves.actor.profile",
"displayName": "Alice Johnson",
},
shouldFail: true,
-
errorContains: "required field missing: handle",
},
{
name: "Valid community profile",
recordType: "social.coves.community.profile",
recordData: map[string]interface{}{
"$type": "social.coves.community.profile",
-
"handle": "programming.community.coves.social",
"name": "programming",
"displayName": "Programming Community",
"createdBy": "did:plc:creator123",
"hostedBy": "did:plc:coves123",
"visibility": "public",
"moderationType": "moderator",
-
"federatedFrom": "coves",
"createdAt": "2023-12-01T08:00:00Z",
},
shouldFail: false,
···
// Test with strict validation flags
recordData := map[string]interface{}{
"$type": "social.coves.actor.profile",
-
"handle": "alice.example.com",
"displayName": "Alice Johnson",
"createdAt": "2024-01-15T10:30:00", // Missing timezone
}
···
// Test specific cross-references that should work
crossRefs := map[string]string{
+
"social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
+
"social.coves.community.rules#rule": "rule definition in community rules",
+
"social.coves.actor.defs#profileView": "profileView definition in actor defs",
+
"social.coves.actor.defs#profileStats": "profileStats definition in actor defs",
+
"social.coves.actor.defs#viewerState": "viewerState definition in actor defs",
+
"social.coves.community.defs#communityView": "communityView definition in community defs",
+
"social.coves.community.defs#communityStats": "communityStats definition in community defs",
}
for ref, description := range crossRefs {
···
recordType: "social.coves.actor.profile",
recordData: map[string]interface{}{
"$type": "social.coves.actor.profile",
"displayName": "Alice Johnson",
"createdAt": "2024-01-15T10:30:00Z",
},
···
recordData: map[string]interface{}{
"$type": "social.coves.actor.profile",
"displayName": "Alice Johnson",
+
// Missing required createdAt
},
shouldFail: true,
+
errorContains: "required field missing",
},
{
name: "Valid community profile",
recordType: "social.coves.community.profile",
recordData: map[string]interface{}{
"$type": "social.coves.community.profile",
"name": "programming",
"displayName": "Programming Community",
"createdBy": "did:plc:creator123",
"hostedBy": "did:plc:coves123",
"visibility": "public",
"moderationType": "moderator",
"createdAt": "2023-12-01T08:00:00Z",
},
shouldFail: false,
···
// Test with strict validation flags
recordData := map[string]interface{}{
"$type": "social.coves.actor.profile",
"displayName": "Alice Johnson",
"createdAt": "2024-01-15T10:30:00", // Missing timezone
}