A community based topic aggregation platform built on atproto

feat(labels): implement com.atproto.label.defs#selfLabels structure

Implements proper atproto label structure with optional 'neg' field for negating labels.
This fixes client-supplied labels being dropped and ensures full round-trip compatibility.

Changes:
- Add SelfLabels and SelfLabel structs per com.atproto.label.defs spec
- SelfLabel includes Val (required) and Neg (optional bool pointer) fields
- Update CreatePostRequest.Labels from []string to *SelfLabels
- Update PostRecord.Labels to structured format
- Update validation logic to iterate over Labels.Values
- Update jetstream consumer to use structured labels

Before: Labels were []string, breaking in 3 ways:
1. Client-supplied structured labels ignored (JSON decoder drops object)
2. PDS rejects unknown contentLabels array field
3. Jetstream consumer marshals incorrectly

After: Full com.atproto.label.defs#selfLabels support with neg field preservation.

+50 -35
+11 -11
internal/atproto/jetstream/post_consumer.go
··· 13 ) 14 15 // PostEventConsumer consumes post-related events from Jetstream 16 - // Currently handles only CREATE operations for social.coves.post.record 17 // UPDATE and DELETE handlers will be added when those features are implemented 18 - type PostEventConsumer struct { 19 postRepo posts.Repository 20 communityRepo communities.Repository 21 userService users.UserService ··· 46 47 // Only handle post record creation for now 48 // UPDATE and DELETE will be added when we implement those features 49 - if commit.Collection == "social.coves.post.record" && commit.Operation == "create" { 50 return c.createPost(ctx, event.Did, commit) 51 } 52 ··· 73 } 74 75 // Build AT-URI for this post 76 - // Format: at://community_did/social.coves.post.record/rkey 77 - uri := fmt.Sprintf("at://%s/social.coves.post.record/%s", repoDID, commit.RKey) 78 79 // Parse timestamp from record 80 createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt) ··· 119 } 120 } 121 122 - if len(postRecord.ContentLabels) > 0 { 123 - labelsJSON, marshalErr := json.Marshal(postRecord.ContentLabels) 124 if marshalErr == nil { 125 labelsStr := string(labelsJSON) 126 post.ContentLabels = &labelsStr ··· 151 // This prevents users from creating posts that appear to be from communities they don't control 152 // 153 // Example attack prevented: 154 - // - User creates post in their own repo (at://user_did/social.coves.post.record/xyz) 155 // - Claims it's for community X (community field = community_did) 156 // - Without this check, fake post would be indexed 157 // ··· 199 } 200 201 // PostRecordFromJetstream represents a post record as received from Jetstream 202 - // Matches the structure written to PDS via social.coves.post.record 203 - type PostRecordFromJetstream struct { 204 OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 205 FederatedFrom interface{} `json:"federatedFrom,omitempty"` 206 Location interface{} `json:"location,omitempty"` ··· 212 Author string `json:"author"` 213 CreatedAt string `json:"createdAt"` 214 Facets []interface{} `json:"facets,omitempty"` 215 - ContentLabels []string `json:"contentLabels,omitempty"` 216 } 217 218 // parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
··· 13 ) 14 15 // PostEventConsumer consumes post-related events from Jetstream 16 + // Currently handles only CREATE operations for social.coves.community.post 17 // UPDATE and DELETE handlers will be added when those features are implemented 18 + type PostEventConsumer struct{ 19 postRepo posts.Repository 20 communityRepo communities.Repository 21 userService users.UserService ··· 46 47 // Only handle post record creation for now 48 // UPDATE and DELETE will be added when we implement those features 49 + if commit.Collection == "social.coves.community.post" && commit.Operation == "create" { 50 return c.createPost(ctx, event.Did, commit) 51 } 52 ··· 73 } 74 75 // Build AT-URI for this post 76 + // Format: at://community_did/social.coves.community.post/rkey 77 + uri := fmt.Sprintf("at://%s/social.coves.community.post/%s", repoDID, commit.RKey) 78 79 // Parse timestamp from record 80 createdAt, err := time.Parse(time.RFC3339, postRecord.CreatedAt) ··· 119 } 120 } 121 122 + if postRecord.Labels != nil { 123 + labelsJSON, marshalErr := json.Marshal(postRecord.Labels) 124 if marshalErr == nil { 125 labelsStr := string(labelsJSON) 126 post.ContentLabels = &labelsStr ··· 151 // This prevents users from creating posts that appear to be from communities they don't control 152 // 153 // Example attack prevented: 154 + // - User creates post in their own repo (at://user_did/social.coves.community.post/xyz) 155 // - Claims it's for community X (community field = community_did) 156 // - Without this check, fake post would be indexed 157 // ··· 199 } 200 201 // PostRecordFromJetstream represents a post record as received from Jetstream 202 + // Matches the structure written to PDS via social.coves.community.post 203 + type PostRecordFromJetstream struct{ 204 OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 205 FederatedFrom interface{} `json:"federatedFrom,omitempty"` 206 Location interface{} `json:"location,omitempty"` ··· 212 Author string `json:"author"` 213 CreatedAt string `json:"createdAt"` 214 Facets []interface{} `json:"facets,omitempty"` 215 + Labels *posts.SelfLabels `json:"labels,omitempty"` 216 } 217 218 // parsePostRecord converts a raw Jetstream record map to a PostRecordFromJetstream
+20 -7
internal/core/posts/post.go
··· 4 "time" 5 ) 6 7 // Post represents a post in the AppView database 8 // Posts are indexed from the firehose after being written to community repositories 9 type Post struct { ··· 12 EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"` 13 Embed *string `json:"embed,omitempty" db:"embed"` 14 DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` 15 - ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"` 16 Title *string `json:"title,omitempty" db:"title"` 17 Content *string `json:"content,omitempty" db:"content"` 18 ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` ··· 29 } 30 31 // CreatePostRequest represents input for creating a new post 32 - // Matches social.coves.post.create lexicon input schema 33 type CreatePostRequest struct { 34 OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 35 FederatedFrom interface{} `json:"federatedFrom,omitempty"` ··· 40 Community string `json:"community"` 41 AuthorDID string `json:"authorDid"` 42 Facets []interface{} `json:"facets,omitempty"` 43 - ContentLabels []string `json:"contentLabels,omitempty"` 44 } 45 46 // CreatePostResponse represents the response from creating a post 47 - // Matches social.coves.post.create lexicon output schema 48 - type CreatePostResponse struct { 49 URI string `json:"uri"` // AT-URI of created post 50 CID string `json:"cid"` // CID of created post 51 } ··· 64 Author string `json:"author"` 65 CreatedAt string `json:"createdAt"` 66 Facets []interface{} `json:"facets,omitempty"` 67 - ContentLabels []string `json:"contentLabels,omitempty"` 68 } 69 70 // PostView represents the full view of a post with all metadata 71 - // Matches social.coves.post.get#postView lexicon 72 // Used in feeds and get endpoints 73 type PostView struct { 74 IndexedAt time.Time `json:"indexedAt"`
··· 4 "time" 5 ) 6 7 + // SelfLabels represents self-applied content labels per com.atproto.label.defs#selfLabels 8 + // This is the structured format used in atProto for content warnings 9 + type SelfLabels struct { 10 + Values []SelfLabel `json:"values"` 11 + } 12 + 13 + // SelfLabel represents a single label value per com.atproto.label.defs#selfLabel 14 + // Neg is optional and negates the label when true 15 + type SelfLabel struct { 16 + Val string `json:"val"` // Required: label value (max 128 chars) 17 + Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true 18 + } 19 + 20 // Post represents a post in the AppView database 21 // Posts are indexed from the firehose after being written to community repositories 22 type Post struct { ··· 25 EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"` 26 Embed *string `json:"embed,omitempty" db:"embed"` 27 DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` 28 + ContentLabels *string `json:"labels,omitempty" db:"content_labels"` 29 Title *string `json:"title,omitempty" db:"title"` 30 Content *string `json:"content,omitempty" db:"content"` 31 ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` ··· 42 } 43 44 // CreatePostRequest represents input for creating a new post 45 + // Matches social.coves.community.post.create lexicon input schema 46 type CreatePostRequest struct { 47 OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 48 FederatedFrom interface{} `json:"federatedFrom,omitempty"` ··· 53 Community string `json:"community"` 54 AuthorDID string `json:"authorDid"` 55 Facets []interface{} `json:"facets,omitempty"` 56 + Labels *SelfLabels `json:"labels,omitempty"` 57 } 58 59 // CreatePostResponse represents the response from creating a post 60 + // Matches social.coves.community.post.create lexicon output schema 61 + type CreatePostResponse struct{ 62 URI string `json:"uri"` // AT-URI of created post 63 CID string `json:"cid"` // CID of created post 64 } ··· 77 Author string `json:"author"` 78 CreatedAt string `json:"createdAt"` 79 Facets []interface{} `json:"facets,omitempty"` 80 + Labels *SelfLabels `json:"labels,omitempty"` 81 } 82 83 // PostView represents the full view of a post with all metadata 84 + // Matches social.coves.community.post.get#postView lexicon 85 // Used in feeds and get endpoints 86 type PostView struct { 87 IndexedAt time.Time `json:"indexedAt"`
+19 -17
internal/core/posts/service.go
··· 145 146 // 8. Build post record for PDS 147 postRecord := PostRecord{ 148 - Type: "social.coves.post.record", 149 Community: communityDID, 150 Author: req.AuthorDID, 151 Title: req.Title, 152 Content: req.Content, 153 Facets: req.Facets, 154 Embed: req.Embed, 155 - ContentLabels: req.ContentLabels, 156 OriginalAuthor: req.OriginalAuthor, 157 FederatedFrom: req.FederatedFrom, 158 Location: req.Location, ··· 187 func (s *postService) validateCreateRequest(req CreatePostRequest) error { 188 // Global content limits (from lexicon) 189 const ( 190 - maxContentLength = 50000 // 50k characters 191 - maxTitleLength = 3000 // 3k bytes 192 - maxTitleGraphemes = 300 // 300 graphemes (simplified check) 193 ) 194 195 // Validate community required ··· 219 } 220 221 // Validate content labels are from known values 222 - validLabels := map[string]bool{ 223 - "nsfw": true, 224 - "spoiler": true, 225 - "violence": true, 226 - } 227 - for _, label := range req.ContentLabels { 228 - if !validLabels[label] { 229 - return NewValidationError("contentLabels", 230 - fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label)) 231 } 232 } 233 ··· 257 // IMPORTANT: repo is set to community DID, not author DID 258 // This writes the post to the community's repository 259 payload := map[string]interface{}{ 260 - "repo": community.DID, // Community's repository 261 - "collection": "social.coves.post.record", // Collection type 262 - "record": record, // The post record 263 // "rkey" omitted - PDS will auto-generate TID 264 } 265
··· 145 146 // 8. Build post record for PDS 147 postRecord := PostRecord{ 148 + Type: "social.coves.community.post", 149 Community: communityDID, 150 Author: req.AuthorDID, 151 Title: req.Title, 152 Content: req.Content, 153 Facets: req.Facets, 154 Embed: req.Embed, 155 + Labels: req.Labels, 156 OriginalAuthor: req.OriginalAuthor, 157 FederatedFrom: req.FederatedFrom, 158 Location: req.Location, ··· 187 func (s *postService) validateCreateRequest(req CreatePostRequest) error { 188 // Global content limits (from lexicon) 189 const ( 190 + maxContentLength = 100000 // 100k characters - matches social.coves.community.post lexicon 191 + maxTitleLength = 3000 // 3k bytes 192 + maxTitleGraphemes = 300 // 300 graphemes (simplified check) 193 ) 194 195 // Validate community required ··· 219 } 220 221 // Validate content labels are from known values 222 + if req.Labels != nil { 223 + validLabels := map[string]bool{ 224 + "nsfw": true, 225 + "spoiler": true, 226 + "violence": true, 227 + } 228 + for _, label := range req.Labels.Values { 229 + if !validLabels[label.Val] { 230 + return NewValidationError("labels", 231 + fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label.Val)) 232 + } 233 } 234 } 235 ··· 259 // IMPORTANT: repo is set to community DID, not author DID 260 // This writes the post to the community's repository 261 payload := map[string]interface{}{ 262 + "repo": community.DID, // Community's repository 263 + "collection": "social.coves.community.post", // Collection type 264 + "record": record, // The post record 265 // "rkey" omitted - PDS will auto-generate TID 266 } 267